Bug/accounting suggestion (#456)

* feat: add bike benefit handling and optional vacation accrual

- Introduced bike benefit (cykelförmån) with calculations for annual market value and monthly taxable value.
- Updated schemas to include new benefit types and validation rules.
- Implemented API routes for creating, updating, and deleting employee benefits.
- Enhanced salary calculation logic to accommodate new vacation rule options, including a 'none' option for no accrual.
- Added UI components for managing employee benefits, including input for bike benefit specifics.
- Created database migrations for employee benefits and updated salary line items to support new benefit types.

* chore: remove Langfuse env var checks

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

* feat: enhance OAuth callback URL handling and update default scopes for Visma integration

* feat: remove trade_name field and simplify company naming in invoices

* refactor: destructure canWrite from useCanWrite for consistency across components

* feat: enhance PATCH endpoint to validate existing benefits and handle bike benefit updates

* feat: add missing label for bike benefit in salary line item types

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-13 00:26:04 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent cd96e5ec26
commit 3fa871c742
53 changed files with 1182 additions and 125 deletions
+12 -2
View File
@@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { Employee } from '@/types'
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
const EMPLOYMENT_LABELS: Record<string, string> = {
employee: 'Anställd',
@@ -36,7 +37,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const canWrite = useCanWrite()
const { canWrite } = useCanWrite()
const [employee, setEmployee] = useState<Employee | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -375,10 +376,16 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12%)</SelectItem>
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
Ingen avsättning till 2920 bokas. Använd om semester ingår i månadslönen — vanligt för ägare som är enda anställd.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
@@ -417,6 +424,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</CardContent>
</Card>
{/* Benefits */}
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
{canWrite && (
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
@@ -24,6 +24,7 @@ export default function NewEmployeePage() {
const [salaryType, setSalaryType] = useState('monthly')
const [fSkattStatus, setFSkattStatus] = useState('a_skatt')
const [isSidoinkomst, setIsSidoinkomst] = useState(false)
const [vacationRule, setVacationRule] = useState('procentregeln')
const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst
@@ -54,6 +55,8 @@ export default function NewEmployeePage() {
city: form.get('city') as string || undefined,
clearing_number: form.get('clearing_number') as string || undefined,
bank_account_number: form.get('bank_account_number') as string || undefined,
vacation_rule: vacationRule,
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
}
const res = await fetch('/api/salary/employees', {
@@ -274,6 +277,40 @@ export default function NewEmployeePage() {
</CardContent>
</Card>
{/* Vacation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vacation_rule">Semesterregel</Label>
<Select value={vacationRule} onValueChange={setVacationRule}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
</div>
</div>
</CardContent>
</Card>
{/* Bank */}
<Card>
<CardHeader>
+1 -1
View File
@@ -21,7 +21,7 @@ const EMPLOYMENT_LABELS: Record<string, string> = {
export default function EmployeesPage() {
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const canWrite = useCanWrite()
const { canWrite } = useCanWrite()
useEffect(() => {
async function load() {
+1 -1
View File
@@ -34,7 +34,7 @@ export default function SalaryPage() {
const [runs, setRuns] = useState<SalaryRun[]>([])
const [employeeCount, setEmployeeCount] = useState(0)
const [loading, setLoading] = useState(true)
const canWrite = useCanWrite()
const { canWrite } = useCanWrite()
useEffect(() => {
async function load() {
@@ -21,6 +21,7 @@ const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
benefit_housing: 'Bostadsförmån',
benefit_meals: 'Kostförmån',
benefit_wellness: 'Friskvård',
benefit_bike: 'Cykelförmån',
benefit_other: 'Övrig förmån',
sick_karens: 'Karensavdrag',
sick_day2_14: 'Sjuklön (dag 2–14, 80 %)',
+3 -1
View File
@@ -21,6 +21,7 @@ import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInpu
import { AGIPanel } from '@/components/salary/AGIPanel'
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
import { TaxPaymentPanel } from '@/components/salary/TaxPaymentPanel'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
type SalaryRunWithArbetsgivare = SalaryRun & { arbetsgivare?: string | null }
@@ -57,7 +58,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const canWrite = useCanWrite()
const { canWrite } = useCanWrite()
const [run, setRun] = useState<SalaryRun | null>(null)
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
@@ -353,6 +354,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
<CardTitle className="text-base">Beräkningsdetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<TaxTableStatus year={run.period_year} compact />
{employees.filter(e => e.calculation_breakdown).map(sre => {
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number | null }> }
return (
@@ -18,7 +18,6 @@ export default function CompanySettingsPage() {
function handleSave(formData: FormData) {
const updates: Record<string, unknown> = {
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
trade_name: (formData.get('trade_name') as string) || null,
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
+18 -3
View File
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
export default function SalarySettingsPage() {
return (
@@ -26,6 +27,20 @@ export default function SalarySettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Skattetabeller</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<TaxTableStatus />
<p className="text-xs text-muted-foreground">
Skattetabeller och kommunala skattesatser hämtas automatiskt från Skatteverkets öppna data
vid varje lönekörning. Ingen manuell uppdatering krävs. Om Skatteverkets API är otillgängligt
används en inbäddad reservkopia tills tjänsten är uppe igen.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</CardTitle>
@@ -34,8 +49,9 @@ export default function SalarySettingsPage() {
<div className="space-y-2">
<label className="text-sm font-medium">Standard semesterregel</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="procentregeln">
<option value="procentregeln">Procentregeln (12%)</option>
<option value="procentregeln">Procentregeln (12 %)</option>
<option value="sammaloneregeln">Sammalöneregeln</option>
<option value="none">Ingen semesteravsättning</option>
</select>
</div>
<div className="space-y-2">
@@ -58,9 +74,8 @@ export default function SalarySettingsPage() {
<CardContent>
<div className="text-sm text-muted-foreground space-y-2">
<p>Lönemodulen hanterar löner för aktiebolag. Enskild firma-ägare använder eget uttag istället.</p>
<p>Arbetsgivaravgifter och skattetabeller uppdateras årligen baserat på Skatteverkets publiceringar.</p>
<p>
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42%, prisbasbelopp 59 200 SEK
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK
</p>
</div>
</CardContent>
+1 -1
View File
@@ -155,7 +155,7 @@ export const POST = withRouteContext(
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.trade_name || company.company_name,
fromName: company.company_name,
attachments: [
{
filename,
+2 -2
View File
@@ -95,11 +95,11 @@ export async function GET(request: Request) {
// Get company name for the consent page
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, trade_name')
.select('company_name')
.eq('company_id', companyId)
.single()
const companyName = settings?.trade_name || settings?.company_name || user.email
const companyName = settings?.company_name || user.email
const appNameLower = escapeHtml(getBranding().appName.toLowerCase())
@@ -0,0 +1,103 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas'
import { calculateBikeBenefit } from '@/lib/salary/benefits'
ensureInitialized()
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string; benefitId: string }> }
) {
const { id, benefitId } = 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 companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateEmployeeBenefitSchema)
if (!validation.success) return validation.response
const body = validation.data
const { data: existing, error: fetchError } = await supabase
.from('employee_benefits')
.select('benefit_type, metadata')
.eq('id', benefitId)
.eq('employee_id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !existing) {
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
}
const updates: Record<string, unknown> = { ...body }
if (body.annual_market_value !== undefined) {
if (existing.benefit_type !== 'bike') {
return NextResponse.json(
{ error: 'annual_market_value gäller endast cykelförmån' },
{ status: 400 },
)
}
const calc = calculateBikeBenefit(body.annual_market_value)
updates.monthly_value = calc.monthlyValue
updates.metadata = {
...(existing.metadata as Record<string, unknown> ?? {}),
...(body.metadata ?? {}),
annual_market_value: body.annual_market_value,
annual_taxable: calc.annualTaxable,
tax_free_portion: calc.taxFreePortion,
}
delete (updates as Record<string, unknown>).annual_market_value
}
const { data, error } = await supabase
.from('employee_benefits')
.update(updates)
.eq('id', benefitId)
.eq('employee_id', id)
.eq('company_id', companyId)
.select()
.single()
if (error || !data) {
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
}
return NextResponse.json({ data })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string; benefitId: string }> }
) {
const { id, benefitId } = 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 companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('employee_benefits')
.delete()
.eq('id', benefitId)
.eq('employee_id', id)
.eq('company_id', companyId)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { id: benefitId, deleted: true } })
}
@@ -0,0 +1,97 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { CreateEmployeeBenefitSchema } from '@/lib/api/schemas'
import { calculateBikeBenefit } from '@/lib/salary/benefits'
ensureInitialized()
export async function GET(
_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 companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('employee_benefits')
.select('*')
.eq('employee_id', id)
.eq('company_id', companyId)
.order('valid_from', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
export async function POST(
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 companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateEmployeeBenefitSchema)
if (!validation.success) return validation.response
const body = validation.data
// Confirm employee belongs to the company
const { data: emp } = await supabase
.from('employees')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
// Bike benefit: derive monthly_value + metadata from the annual market value
let monthlyValue = body.monthly_value ?? 0
let metadata: Record<string, unknown> = body.metadata ?? {}
if (body.benefit_type === 'bike' && body.annual_market_value !== undefined) {
const calc = calculateBikeBenefit(body.annual_market_value)
monthlyValue = calc.monthlyValue
metadata = {
...metadata,
annual_market_value: body.annual_market_value,
annual_taxable: calc.annualTaxable,
tax_free_portion: calc.taxFreePortion,
}
}
const { data, error } = await supabase
.from('employee_benefits')
.insert({
employee_id: id,
company_id: companyId,
user_id: user.id,
benefit_type: body.benefit_type,
description: body.description,
monthly_value: monthlyValue,
valid_from: body.valid_from,
valid_to: body.valid_to ?? null,
metadata,
is_active: body.is_active ?? true,
})
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data }, { status: 201 })
}
+80 -4
View File
@@ -17,6 +17,15 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'parental_leave',
]
const BENEFIT_TYPE_TO_LINE_ITEM: Record<string, SalaryLineItemType> = {
bike: 'benefit_bike',
car: 'benefit_car',
meals: 'benefit_meals',
housing: 'benefit_housing',
wellness: 'benefit_wellness',
other: 'benefit_other',
}
ensureInitialized()
export const POST = withRouteContext(
@@ -239,6 +248,61 @@ export const POST = withRouteContext(
return errorResponse(delAbsErr, opLog, { requestId })
}
// ── Derive benefit line items from employee_benefits ──
// Active benefits = is_active AND payment_date ∈ [valid_from, valid_to]
const { data: activeBenefits, error: benefitsErr } = await supabase
.from('employee_benefits')
.select('id, benefit_type, description, monthly_value')
.eq('employee_id', emp.id)
.eq('company_id', companyId)
.eq('is_active', true)
.lte('valid_from', run.payment_date)
.or(`valid_to.is.null,valid_to.gte.${run.payment_date}`)
if (benefitsErr) {
return errorResponse(benefitsErr, opLog, { requestId })
}
// Replace prior auto-generated benefit rows for this sre.
const { error: delBenefitErr } = await supabase
.from('salary_line_items')
.delete()
.eq('salary_run_employee_id', sre.id)
.not('source_benefit_id', 'is', null)
if (delBenefitErr) {
return errorResponse(delBenefitErr, opLog, { requestId })
}
const derivedBenefitRows = (activeBenefits ?? [])
.filter(b => b.monthly_value > 0)
.map((b, idx) => {
const itemType = BENEFIT_TYPE_TO_LINE_ITEM[b.benefit_type] ?? 'benefit_other'
return {
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: itemType,
description: b.description,
quantity: 1,
amount: Math.round(b.monthly_value * 100) / 100,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: false,
is_net_deduction: false,
account_number: getLineItemAccount(itemType, emp.employment_type),
sort_order: 200 + idx,
source_benefit_id: b.id,
}
})
if (derivedBenefitRows.length > 0) {
const { error: insBenefitErr } = await supabase
.from('salary_line_items')
.insert(derivedBenefitRows)
if (insBenefitErr) {
return errorResponse(insBenefitErr, opLog, { requestId })
}
}
if (absenceResult.lineItems.length > 0) {
const rows = absenceResult.lineItems.map((li, idx) => ({
salary_run_employee_id: sre.id,
@@ -265,10 +329,13 @@ export const POST = withRouteContext(
// Build the merged in-memory line items: keep non-derived items from
// the originally-loaded sre.line_items, then append the freshly-derived
// absence items.
// absence and benefit items.
const manualLineItems = (sre.line_items || [])
.filter((li: Record<string, unknown>) =>
!DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType))
.filter((li: Record<string, unknown>) => {
if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false
if (li.source_benefit_id) return false
return true
})
.map((li: Record<string, unknown>) => ({
itemType: li.item_type as SalaryLineItemType,
amount: li.amount as number,
@@ -287,7 +354,16 @@ export const POST = withRouteContext(
isGrossDeduction: li.is_gross_deduction,
isNetDeduction: false,
}))
const lineItems = [...manualLineItems, ...derivedLineItems]
const derivedBenefitLineItems = derivedBenefitRows.map(row => ({
itemType: row.item_type as SalaryLineItemType,
amount: row.amount,
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
}))
const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems]
const result = calculateSalary(
{
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { fetchTaxTableRates, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
/**
* Probe Skatteverket's open data API to confirm tax tables are reachable.
* Used by the salary settings page to surface that fetching is automatic.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { searchParams } = new URL(request.url)
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()))
try {
const { source } = await fetchTaxTableRates(year, 30, 1)
return NextResponse.json({
data: {
year,
source,
reachable: source === 'api',
checkedAt: new Date().toISOString(),
},
})
} catch (err) {
if (err instanceof TaxTableUnavailableError) {
return NextResponse.json({
data: {
year,
source: 'unavailable' as const,
reachable: false,
checkedAt: new Date().toISOString(),
message: err.message,
},
})
}
throw err
}
}
+267
View File
@@ -0,0 +1,267 @@
'use client'
import { useEffect, useState } from 'react'
import { Card, CardContent, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
type BenefitType = 'bike' | 'car' | 'meals' | 'housing' | 'wellness' | 'other'
interface EmployeeBenefit {
id: string
benefit_type: BenefitType
description: string
monthly_value: number
valid_from: string
valid_to: string | null
metadata: Record<string, unknown>
is_active: boolean
}
const BENEFIT_LABELS: Record<BenefitType, string> = {
bike: 'Cykelförmån',
car: 'Bilförmån',
meals: 'Kostförmån',
housing: 'Bostadsförmån',
wellness: 'Friskvård (skattepliktig del)',
other: 'Övrig förmån',
}
export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const { toast } = useToast()
const [benefits, setBenefits] = useState<EmployeeBenefit[]>([])
const [loading, setLoading] = useState(true)
const [adding, setAdding] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [type, setType] = useState<BenefitType>('bike')
const [description, setDescription] = useState('')
const [monthlyValue, setMonthlyValue] = useState('')
const [annualMarketValue, setAnnualMarketValue] = useState('')
const [validFrom, setValidFrom] = useState(() => new Date().toISOString().slice(0, 10))
const [validTo, setValidTo] = useState('')
async function load() {
setLoading(true)
const res = await fetch(`/api/salary/employees/${employeeId}/benefits`)
if (res.ok) {
const { data } = await res.json()
setBenefits(data || [])
}
setLoading(false)
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [employeeId])
function reset() {
setType('bike')
setDescription('')
setMonthlyValue('')
setAnnualMarketValue('')
setValidFrom(new Date().toISOString().slice(0, 10))
setValidTo('')
setAdding(false)
}
async function handleAdd() {
setSubmitting(true)
const body: Record<string, unknown> = {
benefit_type: type,
description: description || BENEFIT_LABELS[type],
valid_from: validFrom,
}
if (validTo) body.valid_to = validTo
if (type === 'bike') {
body.annual_market_value = parseFloat(annualMarketValue) || 0
} else {
body.monthly_value = parseFloat(monthlyValue) || 0
}
const res = await fetch(`/api/salary/employees/${employeeId}/benefits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
toast({ title: 'Förmån tillagd' })
reset()
await load()
} else {
const result = await res.json()
toast({
title: 'Kunde inte spara förmån',
description: getErrorMessage(result, { statusCode: res.status }),
variant: 'destructive',
})
}
setSubmitting(false)
}
async function handleDelete(id: string) {
const res = await fetch(`/api/salary/employees/${employeeId}/benefits/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Förmån borttagen' })
await load()
} else {
toast({ title: 'Kunde inte ta bort', variant: 'destructive' })
}
}
const previewMonthlyBike = (() => {
const annual = parseFloat(annualMarketValue) || 0
return Math.max(0, annual - 3000) / 12
})()
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Förmåner</CardTitle>
{canWrite && !adding && (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
Lägg till förmån
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<p className="text-sm text-muted-foreground">Laddar…</p>
) : benefits.length === 0 && !adding ? (
<p className="text-sm text-muted-foreground">
Inga förmåner registrerade. Aktiva förmåner läggs till automatiskt som rader vid lönekörning.
</p>
) : (
benefits.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Typ</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Värde/mån</TableHead>
<TableHead>Period</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{benefits.map(b => (
<TableRow key={b.id}>
<TableCell>{BENEFIT_LABELS[b.benefit_type]}</TableCell>
<TableCell className="text-muted-foreground">{b.description}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(b.monthly_value)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-xs">
{formatDate(b.valid_from)}
{b.valid_to ? ` – ${formatDate(b.valid_to)}` : ' – löpande'}
</TableCell>
<TableCell className="text-right">
{canWrite && (
<Button size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label="Ta bort">
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
{adding && (
<div className="space-y-4 rounded-md border bg-muted/30 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="benefit_type">Typ</Label>
<Select value={type} onValueChange={(v) => setType(v as BenefitType)}>
<SelectTrigger id="benefit_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(BENEFIT_LABELS) as BenefitType[]).map(k => (
<SelectItem key={k} value={k}>{BENEFIT_LABELS[k]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="benefit_description">Beskrivning</Label>
<Input
id="benefit_description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={BENEFIT_LABELS[type]}
/>
</div>
</div>
{type === 'bike' ? (
<div className="space-y-2">
<Label htmlFor="annual_market_value">Årligt marknadsvärde (SEK)</Label>
<Input
id="annual_market_value"
type="number"
step="1"
min="0"
value={annualMarketValue}
onChange={e => setAnnualMarketValue(e.target.value)}
placeholder="t.ex. 8 400 (700 kr/mån)"
className="max-w-xs"
/>
<p className="text-xs text-muted-foreground">
Skatteverkets schablon: första 3 000 kr/år är skattefri. Resterande beskattas månadsvis.
{parseFloat(annualMarketValue) > 0 && (
<span className="ml-1">
Månatligt förmånsvärde: <strong className="tabular-nums">{formatCurrency(previewMonthlyBike)}</strong>
</span>
)}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="monthly_value">Månatligt förmånsvärde (SEK)</Label>
<Input
id="monthly_value"
type="number"
step="1"
min="0"
value={monthlyValue}
onChange={e => setMonthlyValue(e.target.value)}
className="max-w-xs"
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="valid_from">Gäller från</Label>
<Input id="valid_from" type="date" value={validFrom} onChange={e => setValidFrom(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="valid_to">Gäller till (valfritt)</Label>
<Input id="valid_to" type="date" value={validTo} onChange={e => setValidTo(e.target.value)} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={reset} disabled={submitting}>Avbryt</Button>
<Button size="sm" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)
}
+49 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Download, Loader2, CheckCircle2 } from 'lucide-react'
import { Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -30,6 +30,20 @@ const FORMAT_DESCRIPTION: Record<PaymentFormat, string> = {
pain001: 'ISO 20022. För banker som inte är anslutna till Bankgirot, eller internationell SEPA.',
}
const BANK_INSTRUCTIONS: Record<PaymentFormat, Array<{ bank: string; steps: string }>> = {
bg_lb: [
{ bank: 'Swedbank Företag', steps: 'Företagsbetalningar → Importera fil → välj LB-format → ladda upp och signera med BankID.' },
{ bank: 'SEB Företag', steps: 'Betalningar → Filöverföring → välj Bankgiro LB → ladda upp och attestera.' },
{ bank: 'Handelsbanken', steps: 'Betala → Filimport → välj LB → kontrollera summor → signera.' },
{ bank: 'Nordea Företag', steps: 'Filimport → Bankgiro LB → ladda upp → attestera betalningen.' },
],
pain001: [
{ bank: 'SEB', steps: 'Betalningar → Importera SEPA / ISO 20022 (pain.001) → ladda upp och signera.' },
{ bank: 'Handelsbanken', steps: 'Betala → Filimport → ISO 20022 → välj pain.001.' },
{ bank: 'Nordea', steps: 'Filimport → Format pain.001.001.03 → ladda upp → attestera.' },
],
}
export function PaymentFilePanel({
salaryRunId,
periodLabel,
@@ -42,6 +56,7 @@ export function PaymentFilePanel({
const { toast } = useToast()
const [format, setFormat] = useState<PaymentFormat>(defaultFormat)
const [downloading, setDownloading] = useState(false)
const [showInstructions, setShowInstructions] = useState(false)
const endpoint =
format === 'bg_lb'
@@ -123,6 +138,39 @@ export function PaymentFilePanel({
Ladda ner betalfil
</Button>
</div>
<button
type="button"
onClick={() => setShowInstructions(s => !s)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
aria-expanded={showInstructions}
>
<ChevronDown className={`h-3 w-3 transition-transform ${showInstructions ? 'rotate-180' : ''}`} />
Så importerar du filen i din bank
</button>
{showInstructions && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-xs">
{BANK_INSTRUCTIONS[format].map(b => (
<div key={b.bank}>
<strong className="text-foreground">{b.bank}.</strong>{' '}
<span className="text-muted-foreground">{b.steps}</span>
</div>
))}
<p className="pt-1 text-muted-foreground border-t mt-2">
Filen innehåller både lön till anställda och beloppen är förberedda — bankens BankID-signering är det enda steget kvar.
</p>
</div>
)}
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/20 p-3 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
<strong className="text-foreground">Open Payments / direktbetalning</strong> via PSD2 (utan filimport)
är planerat för framtiden via Enable Banking. Initiering kräver separat PIS-avtal — vi följer upp när
tillräckligt många kunder använder lönebetalfiler regelbundet.
</span>
</div>
</>
)}
</CardContent>
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { useEffect, useState } from 'react'
import { Globe, CheckCircle2, AlertTriangle, RefreshCcw, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
type Source = 'api' | 'fallback' | 'unavailable'
interface Status {
year: number
source: Source
reachable: boolean
checkedAt: string
}
interface Props {
year?: number
compact?: boolean
}
export function TaxTableStatus({ year, compact = false }: Props) {
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
async function check() {
setLoading(true)
const params = new URLSearchParams()
if (year) params.set('year', String(year))
const res = await fetch(`/api/salary/tax-tables/status?${params}`)
if (res.ok) {
const { data } = await res.json()
setStatus(data)
}
setLoading(false)
}
useEffect(() => {
check()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [year])
if (loading && !status) {
return (
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Kontrollerar skattetabeller…
</div>
)
}
if (!status) return null
const Icon = status.source === 'api' ? CheckCircle2 : AlertTriangle
const iconColor =
status.source === 'api' ? 'text-success' :
status.source === 'fallback' ? 'text-warning' :
'text-destructive'
const label =
status.source === 'api'
? `Skattetabeller för ${status.year} hämtas live från Skatteverket`
: status.source === 'fallback'
? `Skatteverkets API är inte nåbart — använder lokal reservdata för ${status.year}`
: `Skattetabeller för ${status.year} kunde inte hämtas`
if (compact) {
return (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Globe className="h-3 w-3" />
{label}
</p>
)
}
return (
<div className="flex items-center justify-between gap-3 rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2 text-sm">
<Icon className={`h-4 w-4 ${iconColor}`} />
<span>{label}</span>
</div>
<Button variant="ghost" size="sm" onClick={check} disabled={loading} aria-label="Kontrollera igen">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
</Button>
</div>
)
}
+1 -14
View File
@@ -22,22 +22,9 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
disabled={settings.onboarding_complete === true}
/>
{settings.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="trade_name">Handelsnamn</Label>
<Input
id="trade_name"
name="trade_name"
defaultValue={settings.trade_name || ''}
placeholder="Visas på fakturor istället för företagsnamnet"
/>
<p className="text-xs text-muted-foreground">
Valfritt. Visas som huvudnamn på fakturor och e-post, med det juridiska namnet i parentes.
Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).
</p>
</div>
<div className="space-y-2">
+11
View File
@@ -95,6 +95,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Visa logga</Label>
<p className="text-xs text-muted-foreground">Visa uppladdad logga i fakturahuvudet</p>
</div>
<Switch
checked={settings.invoice_show_logo ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_logo', v)}
/>
</div>
</div>
<div className="space-y-4 pt-2">
@@ -90,12 +90,25 @@ export default function JournalEntryPreview({
const vatRate = templateVatRate ?? 0
const vatAmt = extractVatAmount(absAmount, vatRate)
const netAmt = extractNetAmount(absAmount, vatRate)
const isIncome = amount > 0
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
if (vatAmt > 0) {
result.push({ side: 'debet', account: '2641', amount: vatAmt })
if (isIncome) {
// Income: debit bank gross, credit revenue net, credit output VAT
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount })
result.push({ side: 'kredit', account: templateCreditAccount, amount: netAmt })
if (vatAmt > 0) {
// Map rate → output VAT account (BAS 2611/2621/2631)
const outputVatAccount = vatRate === 0.06 ? '2631' : vatRate === 0.12 ? '2621' : '2611'
result.push({ side: 'kredit', account: outputVatAccount, amount: vatAmt })
}
} else {
// Expense: debit expense net + input VAT, credit bank gross
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
if (vatAmt > 0) {
result.push({ side: 'debet', account: '2641', amount: vatAmt })
}
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
}
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
return result
}
+15 -2
View File
@@ -248,9 +248,22 @@ export const arcimMigrationExtension: Extension = {
// Generate OTC for OAuth flow
const otc = await generateOtc(consent.id)
// Build the OAuth callback URL
// Build the OAuth callback URL. Prefer a provider-specific override
// (e.g. VISMA_REDIRECT_URI) when set — this lets dev environments
// route through a single registered URI (production) rather than
// requiring every ngrok URL to be registered on the OAuth client.
// Falls back to NEXT_PUBLIC_APP_URL + the canonical callback path.
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback`
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
const callbackUrl =
providerRedirectEnv && providerRedirectEnv.trim().length > 0
? providerRedirectEnv
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Encode consentId + provider in state
const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider })
@@ -137,7 +137,6 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${getCompanyPrimaryName(company)}</strong>
${company.trade_name && company.company_name ? `<br><span style="font-weight: normal; font-size: 12px; color: #999;">(${company.company_name})</span>` : ''}
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
@@ -180,7 +180,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${getCompanyPrimaryName(company)}</strong>
${company.trade_name && company.company_name ? `<br><span style="font-weight: normal; font-size: 12px; color: #999;">(${company.company_name})</span>` : ''}
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
@@ -19,7 +19,7 @@ export const companyCurrentResource: McpResource = {
const { data: settings } = await supabase
.from('company_settings')
.select(`
company_name, trade_name, address_line1, address_line2, postal_code, city, country,
company_name, address_line1, address_line2, postal_code, city, country,
phone, email, website,
pays_salaries, f_skatt, vat_registered, vat_number, moms_period,
fiscal_year_start_month,
+44 -3
View File
@@ -359,7 +359,6 @@ export const MatchSupplierInvoiceSchema = z.object({
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
trade_name: z.string().nullable().optional(),
org_number: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
@@ -399,6 +398,7 @@ export const UpdateSettingsSchema = z.object({
invoice_show_ocr: z.boolean().optional(),
invoice_show_bankgiro: z.boolean().optional(),
invoice_show_plusgiro: z.boolean().optional(),
invoice_show_logo: z.boolean().optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
// AI agent flow
@@ -677,13 +677,13 @@ export const SupplierImportExecuteSchema = z.object({
export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_member'])
export const SalaryTypeSchema = z.enum(['monthly', 'hourly'])
export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'])
export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln'])
export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'none'])
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
export const SalaryLineItemTypeSchema = z.enum([
'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other',
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
'vab', 'parental_leave', 'vacation',
'traktamente_taxfree', 'traktamente_taxable',
@@ -795,6 +795,47 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
}
})
export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
export const CreateEmployeeBenefitSchema = z.object({
benefit_type: EmployeeBenefitTypeSchema,
description: z.string().min(1).max(200),
monthly_value: z.number().nonnegative().optional(),
/** For bike benefit: annual market value of the förmån. The server computes
* monthly_value = max(0, annual − 3000) / 12 per Skatteverket schablon. */
annual_market_value: z.number().nonnegative().optional(),
valid_from: isoDate,
valid_to: isoDate.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
is_active: z.boolean().optional(),
}).superRefine((data, ctx) => {
if (data.benefit_type === 'bike') {
if (data.annual_market_value === undefined && data.monthly_value === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Cykelförmån kräver årligt marknadsvärde',
path: ['annual_market_value'],
})
}
} else if (data.monthly_value === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Månatligt förmånsvärde krävs',
path: ['monthly_value'],
})
}
})
export const UpdateEmployeeBenefitSchema = z.object({
description: z.string().min(1).max(200).optional(),
monthly_value: z.number().nonnegative().optional(),
annual_market_value: z.number().nonnegative().optional(),
valid_from: isoDate.optional(),
valid_to: isoDate.nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
is_active: z.boolean().optional(),
})
export const CreateSalaryRunSchema = z.object({
period_year: z.number().int().min(2020).max(2100),
period_month: z.number().int().min(1).max(12),
-1
View File
@@ -137,7 +137,6 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${getCompanyPrimaryName(company)}</strong>
${company.trade_name && company.company_name ? `<br><span style="font-weight: normal; font-size: 12px; color: #999;">(${company.company_name})</span>` : ''}
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
-1
View File
@@ -180,7 +180,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${getCompanyPrimaryName(company)}</strong>
${company.trade_name && company.company_name ? `<br><span style="font-weight: normal; font-size: 12px; color: #999;">(${company.company_name})</span>` : ''}
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
-11
View File
@@ -24,11 +24,6 @@ const REQUIRED_EXTENSION_VARS = [
'OPENAI_API_KEY',
] as const
const OPTIONAL_VARS = [
'LANGFUSE_SECRET_KEY',
'LANGFUSE_PUBLIC_KEY',
] as const
function validateEnvironment(): void {
// During builds (CI, Docker, Vercel), env vars may be absent or set to
// placeholder sentinels. Skip validation so Next.js page collection
@@ -54,12 +49,6 @@ function validateEnvironment(): void {
if (missingExt.length > 0) {
log.warn(`Missing extension environment variables (extensions needing them may not work): ${missingExt.join(', ')}`)
}
for (const v of OPTIONAL_VARS) {
if (!process.env[v]) {
log.warn(`Optional environment variable ${v} is not set`)
}
}
}
/**
+10 -19
View File
@@ -374,21 +374,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Header */}
<View style={styles.header}>
<View style={styles.companyInfo}>
{company.logo_url && (
{company.logo_url && (company.invoice_show_logo ?? true) && (
<Image src={company.logo_url} style={{ maxHeight: 40, maxWidth: 150, marginBottom: 6, alignSelf: 'flex-start' }} />
)}
<Text style={styles.companyName}>{company.trade_name || company.company_name}</Text>
{company.trade_name && company.company_name && (
<Text style={{ fontSize: 8, color: '#666' }}>({company.company_name})</Text>
)}
{company.address_line1 && <Text>{company.address_line1}</Text>}
{(company.postal_code || company.city) && (
<Text>{company.postal_code} {company.city}</Text>
)}
{company.org_number && (
<Text style={{ marginTop: 4 }}>Org.nr: {formatOrgNumber(company.org_number)}</Text>
)}
{company.vat_number && <Text>VAT: {company.vat_number}</Text>}
<Text style={styles.companyName}>{company.company_name}</Text>
</View>
<View style={{ textAlign: 'right' }}>
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
@@ -672,14 +661,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
)}
{/* Footer */}
{/* Footer — collected legal info per ML 17 kap 24§ */}
<View style={styles.footer}>
<Text style={styles.footerText}>
{company.trade_name || company.company_name}
{company.trade_name && company.company_name ? ` (${company.company_name})` : ''}
{company.org_number ? ` | Org.nr: ${formatOrgNumber(company.org_number)}` : ''}
{company.f_skatt ? ' | Godkänd för F-skatt' : ''}
{company.vat_number ? ` | Momsreg.nr: ${company.vat_number}` : ''}
{[
company.address_line1,
(company.postal_code || company.city) ? `${company.postal_code ?? ''} ${company.city ?? ''}`.trim() : null,
company.org_number ? `Org.nr: ${formatOrgNumber(company.org_number)}` : null,
company.vat_number ? `Momsreg.nr: ${company.vat_number}` : null,
company.f_skatt ? 'Godkänd för F-skatt' : null,
].filter(Boolean).join(' · ')}
</Text>
</View>
</Page>
+1 -1
View File
@@ -116,7 +116,7 @@ export async function sendReminder(
html: generateReminderEmailHtml(emailData),
text: generateReminderEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.trade_name || company.company_name || undefined
fromName: company.company_name || undefined
})
return result
+1 -1
View File
@@ -616,7 +616,7 @@ async function commitSendInvoice(
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.trade_name || company.company_name,
fromName: company.company_name,
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
})
+5 -4
View File
@@ -7,11 +7,12 @@ import {
} from '@/lib/http/fetch-with-timeout';
const DEFAULT_SCOPES = [
'ea:api',
'offline_access',
'ea:sales_readonly',
'ea:accounting_readonly',
'ea:purchase_readonly',
'ea:api',
'ea:sales',
'ea:accounting',
'ea:purchase',
'vls:api',
];
const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934';
@@ -5,8 +5,7 @@ import type { CompanySettings } from '@/types'
function fakeCompany(): CompanySettings {
return {
company_name: 'Gnubok AB',
trade_name: 'Gnubok',
company_name: 'Gnubok',
org_number: '5566778899',
vat_number: 'SE556677889901',
address_line1: 'Kungsgatan 1',
@@ -67,7 +67,6 @@ const mockGetAuditLog = vi.mocked(getAuditLog)
const COMPANY_ROW = {
company_name: 'Test AB',
trade_name: null,
org_number: '5566778899',
moms_period: 'quarterly',
}
@@ -265,7 +265,7 @@ export function FinancialStatementPDF({
company,
generatedAt,
}: FinancialStatementPDFProps) {
const companyDisplayName = company.trade_name || company.company_name || ''
const companyDisplayName = company.company_name || ''
const periodLabel = period.start && period.end
? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}`
: ''
+1 -4
View File
@@ -43,7 +43,6 @@ interface FiscalPeriodRow {
interface CompanyInfo {
company_name: string | null
trade_name: string | null
org_number: string | null
moms_period: string | null
}
@@ -116,7 +115,6 @@ export async function generateFullArchive(
const sie = await generateSIEExport(supabase, companyId, {
fiscal_period_id: period.id,
company_name: company.company_name || 'Unknown',
trade_name: company.trade_name,
org_number: company.org_number,
})
sieFolder.file(`${periodLabel(period)}.se`, sie)
@@ -132,7 +130,6 @@ export async function generateFullArchive(
const sie = await generateSIEExport(supabase, companyId, {
fiscal_period_id: period.id,
company_name: company.company_name || 'Unknown',
trade_name: company.trade_name,
org_number: company.org_number,
})
zip.file('bokforing.se', sie)
@@ -227,7 +224,7 @@ export async function estimateArchiveSize(
async function fetchCompany(supabase: SupabaseClient, companyId: string): Promise<CompanyInfo> {
const { data } = await supabase
.from('company_settings')
.select('company_name, trade_name, org_number, moms_period')
.select('company_name, org_number, moms_period')
.eq('company_id', companyId)
.single()
+2 -2
View File
@@ -699,7 +699,7 @@ export async function generateINK2Declaration(
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, trade_name, org_number, entity_type, address_line1, postal_code, city, email')
.select('company_name, org_number, entity_type, address_line1, postal_code, city, email')
.eq('company_id', companyId)
.single()
@@ -934,7 +934,7 @@ export async function generateINK2Declaration(
resultAfterFinancial,
},
companyInfo: {
companyName: settings?.trade_name || settings?.company_name || 'Okänt företag',
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
addressLine1: settings?.address_line1 || null,
postalCode: settings?.postal_code || null,
+2 -2
View File
@@ -175,7 +175,7 @@ export async function generateNEDeclaration(
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, trade_name, org_number, entity_type')
.select('company_name, org_number, entity_type')
.eq('company_id', companyId)
.single()
@@ -331,7 +331,7 @@ export async function generateNEDeclaration(
rutor,
breakdown,
companyInfo: {
companyName: settings?.trade_name || settings?.company_name || 'Okänt företag',
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
},
warnings,
@@ -255,7 +255,7 @@ interface CommonHeaderProps {
}
function HeaderBlock({ title, company, period }: CommonHeaderProps) {
const companyDisplayName = company.trade_name || company.company_name || ''
const companyDisplayName = company.company_name || ''
const periodLabel = period.start && period.end
? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}`
: ''
@@ -288,7 +288,7 @@ function HeaderBlock({ title, company, period }: CommonHeaderProps) {
}
function FooterBlock({ company, generatedAt }: { company: CompanySettings; generatedAt: string }) {
const companyDisplayName = company.trade_name || company.company_name || ''
const companyDisplayName = company.company_name || ''
return (
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
+1 -1
View File
@@ -94,7 +94,7 @@ export async function generateSIEExport(
lines.push(`#ORGNR ${options.org_number}`)
}
lines.push(`#FNAMN "${escapeQuotes(options.trade_name || options.company_name)}"`)
lines.push(`#FNAMN "${escapeQuotes(options.company_name)}"`)
// === Fiscal year ===
// #RAR 0 = current year, #RAR -1 = previous year (both should be present per spec)
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { calculateCarBenefit, getMealBenefitValue, calculateWellnessBenefit } from '../benefits'
import { calculateCarBenefit, getMealBenefitValue, calculateWellnessBenefit, calculateBikeBenefit, BIKE_BENEFIT_TAX_FREE_ALLOWANCE } from '../benefits'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
@@ -151,3 +151,29 @@ describe('calculateWellnessBenefit', () => {
expect(result.taxableAmount).toBe(6000)
})
})
describe('calculateBikeBenefit', () => {
it('is tax-free entirely when annual value is below the 3 000 kr schablon', () => {
const result = calculateBikeBenefit(2500)
expect(result.annualTaxable).toBe(0)
expect(result.monthlyValue).toBe(0)
expect(result.taxFreePortion).toBe(2500)
})
it('taxes only the excess over 3 000 kr', () => {
const result = calculateBikeBenefit(8400)
// 8400 - 3000 = 5400 taxable annually → 450/month
expect(result.annualTaxable).toBe(5400)
expect(result.monthlyValue).toBe(450)
expect(result.taxFreePortion).toBe(3000)
})
it('handles 0 or negative input gracefully', () => {
expect(calculateBikeBenefit(0).monthlyValue).toBe(0)
expect(calculateBikeBenefit(-100).monthlyValue).toBe(0)
})
it('exposes the schablon constant', () => {
expect(BIKE_BENEFIT_TAX_FREE_ALLOWANCE).toBe(3000)
})
})
+15 -1
View File
@@ -173,13 +173,27 @@ export function calculateParentalLeaveDeduction(
export function calculateVacationPay(params: {
monthlySalary: number
vacationDaysTaken: number
vacationRule: 'procentregeln' | 'sammaloneregeln'
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none'
semestertillaggRate: number
vacationDaysPerYear: number
}): { amount: number; tillagg: number; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(params.monthlySalary / 21)
if (params.vacationRule === 'none') {
// No accrual — vacation is included in monthly pay. No tillägg paid.
return {
amount: 0,
tillagg: 0,
steps: [{
label: 'Semesterlön (avstängd)',
formula: 'ingen separat semesterlön — ingår i månadslönen',
input: { days: params.vacationDaysTaken },
output: 0,
}],
}
}
if (params.vacationRule === 'sammaloneregeln') {
// Sammalöneregeln: regular pay + semestertillägg per day
const tillagg = r(params.monthlySalary * params.semestertillaggRate * params.vacationDaysTaken)
+1
View File
@@ -21,6 +21,7 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
benefit_housing: '7381',
benefit_meals: '7382',
benefit_wellness: '7699',
benefit_bike: '7388',
benefit_other: '7389',
// Absence
sick_karens: '7281',
+1
View File
@@ -80,6 +80,7 @@ export const BENEFIT_RUTA_MAP: Record<string, string> = {
benefit_housing: INDIVID_FIELDS.RUTA_014,
benefit_meals: INDIVID_FIELDS.RUTA_015,
benefit_wellness: INDIVID_FIELDS.RUTA_019,
benefit_bike: INDIVID_FIELDS.RUTA_019,
benefit_other: INDIVID_FIELDS.RUTA_019,
}
+50
View File
@@ -120,6 +120,56 @@ export function getMealBenefitValue(mealType: MealType, config: PayrollConfig):
}
}
// ============================================================
// Bike Benefit (Cykelförmån)
// ============================================================
/**
* Annual tax-free allowance for cykelförmån, in SEK.
* Per Skatteverket schablon effective from 2022: the first 3 000 kr/year of
* a bike benefit is tax-free; the excess is taxable. The employer must offer
* the benefit on equal terms to all employees.
*/
export const BIKE_BENEFIT_TAX_FREE_ALLOWANCE = 3000
/**
* Calculate monthly taxable cykelförmån from an annual market value.
*
* taxable_annual = max(0, annual_market_value - 3000)
* taxable_monthly = taxable_annual / 12
*
* `annualMarketValue` is the yearly värde av förmånen — typically the
* marknadsmässiga hyran or the subscription cost the employer pays.
*/
export function calculateBikeBenefit(annualMarketValue: number): {
monthlyValue: number
annualTaxable: number
taxFreePortion: number
steps: BenefitStep[]
} {
const r = (x: number) => Math.round(x * 100) / 100
const annual = Math.max(0, annualMarketValue)
const taxFreePortion = Math.min(annual, BIKE_BENEFIT_TAX_FREE_ALLOWANCE)
const annualTaxable = r(annual - taxFreePortion)
const monthlyValue = r(annualTaxable / 12)
return {
monthlyValue,
annualTaxable,
taxFreePortion,
steps: [{
label: 'Cykelförmån (schablon)',
formula: 'max(0, årligt marknadsvärde − 3 000 kr skattefritt) / 12',
input: {
annual_market_value: annual,
tax_free_allowance: BIKE_BENEFIT_TAX_FREE_ALLOWANCE,
annual_taxable: annualTaxable,
},
output: monthlyValue,
}],
}
}
// ============================================================
// Wellness Benefit (Friskvårdsbidrag)
// ============================================================
+22 -4
View File
@@ -31,7 +31,7 @@ export interface SalaryCalculationInput {
paymentDate: string
/** Vacation */
vacationRule: 'procentregeln' | 'sammaloneregeln'
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none'
vacationDaysPerYear: number
semestertillaggRate: number
@@ -217,7 +217,7 @@ export function calculateSalary(
// ─── Step 5: Add förmånsvärden to tax base ───
const benefitItems = input.lineItems.filter(
li => ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other'].includes(li.itemType)
li => ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other'].includes(li.itemType)
)
const totalBenefits = r(benefitItems.reduce((sum, li) => sum + li.amount, 0))
if (totalBenefits > 0) {
@@ -355,7 +355,15 @@ export function calculateSalary(
baseSalary + vacationBasisItems.reduce((sum, li) => sum + li.amount, 0)
)
let vacationAccrual: number
if (input.vacationRule === 'procentregeln') {
if (input.vacationRule === 'none') {
vacationAccrual = 0
steps.push({
label: 'Semesteravsättning (avstängd)',
formula: 'ingen semesteravsättning bokas — semester ingår i månadslönen',
input: {},
output: 0,
})
} else if (input.vacationRule === 'procentregeln') {
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
vacationAccrual = r(vacationBasis * rate)
steps.push({
@@ -585,13 +593,23 @@ export function calculateSjuklon(
*/
export function calculateVacationAccrual(params: {
monthlySalary: number
vacationRule: 'procentregeln' | 'sammaloneregeln'
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none'
vacationDaysPerYear: number
semestertillaggRate: number
vacationBasis: number
}): { accrual: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
if (params.vacationRule === 'none') {
steps.push({
label: 'Semesteravsättning (avstängd)',
formula: 'ingen semesteravsättning',
input: {},
output: 0,
})
return { accrual: 0, steps }
}
if (params.vacationRule === 'procentregeln') {
const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12
const accrual = r(params.vacationBasis * rate)
+1 -1
View File
@@ -142,7 +142,7 @@ async function createSalaryEntry(
// Add salary line items that are cash expenses
// Förmånsvärden (benefits) are excluded — they affect the tax base but
// have no cash flow and should not appear as expense lines in the journal.
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other']
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other']
let lineItemTotal = 0
for (const li of emp.line_items) {
if (li.is_net_deduction || li.is_gross_deduction) continue
+4 -17
View File
@@ -46,25 +46,12 @@ export function formatOrgNumber(orgNumber: string): string {
return orgNumber
}
/**
* Returns the display name for a company, using trade name as primary
* with legal name in parentheses if both exist.
*/
export function getCompanyDisplayName(settings: { trade_name?: string | null; company_name?: string | null }): string {
const tradeName = settings.trade_name?.trim()
const legalName = settings.company_name?.trim()
if (tradeName && legalName) {
return `${tradeName} (${legalName})`
}
return legalName || tradeName || ''
export function getCompanyDisplayName(settings: { company_name?: string | null }): string {
return settings.company_name?.trim() || ''
}
/**
* Returns just the primary name for contexts where a short name is needed
* (e.g. email from name). Uses trade name if set, otherwise legal name.
*/
export function getCompanyPrimaryName(settings: { trade_name?: string | null; company_name?: string | null }): string {
return settings.trade_name?.trim() || settings.company_name?.trim() || ''
export function getCompanyPrimaryName(settings: { company_name?: string | null }): string {
return settings.company_name?.trim() || ''
}
export function generateInvoiceNumber(): string {
@@ -0,0 +1,17 @@
-- =============================================================================
-- Salary: optional vacation accrual ("none" mode)
-- =============================================================================
-- Adds a third value to employees.vacation_rule so a company can disable
-- semesteravsättning entirely. Useful for sole owners (ägare = enda anställd)
-- who don't accrue semester — booking otherwise creates a phantom 2920 liability.
--
-- procentregeln — 12% (or 14.4% for 30+ days) accrued to 2920 (Semesterlagen 26§)
-- sammaloneregeln — semestertillägg only (Semesterlagen 16a§)
-- none — no accrual; salary expense is the full cost
ALTER TABLE public.employees DROP CONSTRAINT IF EXISTS employees_vacation_rule_check;
ALTER TABLE public.employees ADD CONSTRAINT employees_vacation_rule_check
CHECK (vacation_rule IN ('procentregeln', 'sammaloneregeln', 'none'));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,93 @@
-- =============================================================================
-- Employee benefits (förmånshantering)
-- =============================================================================
-- Per-employee benefit assignments that drive auto-generated line items on
-- every salary run. Today the engine has helpers for car/meal/wellness benefits
-- but no link from a specific employee to a specific benefit instance — meaning
-- users had to add a benefit_* line item manually each month. This table closes
-- that gap.
--
-- Includes bike benefit (cykelförmån, skattefri schablon 3 000 kr/år from 2022,
-- Skatteverket).
CREATE TABLE public.employee_benefits (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
employee_id uuid NOT NULL REFERENCES public.employees(id) ON DELETE CASCADE,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
benefit_type text NOT NULL
CHECK (benefit_type IN ('bike', 'car', 'meals', 'housing', 'wellness', 'other')),
description text NOT NULL,
-- Monthly taxable förmånsvärde (SEK). For bike, computed as
-- max(0, annual_market_value - 3000) / 12 per Skatteverket schablon.
monthly_value numeric NOT NULL CHECK (monthly_value >= 0),
valid_from date NOT NULL,
valid_to date,
-- Free-form context: bike → { annual_market_value, tax_free_allowance }
-- car → { nybilspris, environmental_type, ... }
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (valid_to IS NULL OR valid_to >= valid_from)
);
ALTER TABLE public.employee_benefits ENABLE ROW LEVEL SECURITY;
CREATE POLICY "employee_benefits_select" ON public.employee_benefits
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "employee_benefits_insert" ON public.employee_benefits
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "employee_benefits_update" ON public.employee_benefits
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "employee_benefits_delete" ON public.employee_benefits
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE INDEX idx_employee_benefits_employee ON public.employee_benefits (employee_id);
CREATE INDEX idx_employee_benefits_company ON public.employee_benefits (company_id);
CREATE INDEX idx_employee_benefits_active
ON public.employee_benefits (employee_id, valid_from, valid_to)
WHERE is_active = true;
CREATE TRIGGER employee_benefits_updated_at
BEFORE UPDATE ON public.employee_benefits
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Link a salary_line_items row back to the employee_benefits row that produced
-- it. Used by the calculate flow to delete-then-recreate benefit line items
-- without disturbing manually-added line items.
ALTER TABLE public.salary_line_items
ADD COLUMN source_benefit_id uuid REFERENCES public.employee_benefits(id) ON DELETE SET NULL;
CREATE INDEX idx_salary_line_items_source_benefit
ON public.salary_line_items (source_benefit_id)
WHERE source_benefit_id IS NOT NULL;
-- Extend salary_line_items to accept a benefit_bike type for cykelförmån.
ALTER TABLE public.salary_line_items DROP CONSTRAINT IF EXISTS salary_line_items_item_type_check;
ALTER TABLE public.salary_line_items ADD CONSTRAINT salary_line_items_item_type_check
CHECK (item_type IN (
'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness',
'benefit_bike', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
'vab', 'parental_leave', 'vacation',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
'net_deduction_other',
'correction', 'other'
));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,32 @@
-- Simplify company naming: remove trade_name field entirely.
-- Add invoice_show_logo toggle for invoice PDF header.
-- Migrate any existing trade_name into company_name so users who set a
-- handelsnamn keep their preferred invoice display name. Existing users
-- should be notified to verify company_name against Bolagsverket since
-- this value is used in SIE/INK2/NE filings.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS invoice_show_logo boolean DEFAULT true;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'company_settings'
AND column_name = 'trade_name'
) THEN
EXECUTE $sql$
UPDATE public.company_settings
SET company_name = trade_name
WHERE trade_name IS NOT NULL
AND trade_name <> ''
AND trade_name <> company_name
$sql$;
END IF;
END $$;
ALTER TABLE public.company_settings
DROP COLUMN IF EXISTS trade_name;
NOTIFY pgrst, 'reload schema';
+1 -1
View File
@@ -494,7 +494,6 @@ export function makeCompanySettings(
company_id: 'company-1',
entity_type: 'enskild_firma',
company_name: 'Test Firma',
trade_name: null,
org_number: '199001011234',
address_line1: 'Testgatan 1',
address_line2: null,
@@ -531,6 +530,7 @@ export function makeCompanySettings(
invoice_show_ocr: true,
invoice_show_bankgiro: true,
invoice_show_plusgiro: true,
invoice_show_logo: true,
invoice_late_fee_text: null,
invoice_credit_terms_text: null,
logo_url: null,
+3 -4
View File
@@ -177,7 +177,6 @@ export interface CompanySettings {
// Entity info
entity_type: EntityType
company_name: string | null
trade_name: string | null
org_number: string | null
// Address
@@ -240,6 +239,7 @@ export interface CompanySettings {
invoice_show_ocr: boolean
invoice_show_bankgiro: boolean
invoice_show_plusgiro: boolean
invoice_show_logo: boolean
invoice_late_fee_text: string | null
invoice_credit_terms_text: string | null
@@ -1264,7 +1264,6 @@ export interface BalansrapportReport {
export interface SIEExportOptions {
fiscal_period_id: string
company_name: string
trade_name?: string | null
org_number: string | null
program_name?: string
}
@@ -2481,7 +2480,7 @@ export interface KPIPreferences {
export type EmploymentType = 'employee' | 'company_owner' | 'board_member'
export type SalaryType = 'monthly' | 'hourly'
export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified'
export type VacationRule = 'procentregeln' | 'sammaloneregeln'
export type VacationRule = 'procentregeln' | 'sammaloneregeln' | 'none'
export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected'
export type AGIStatus =
| 'generated' // XML built from a salary run; nothing sent to SKV yet
@@ -2494,7 +2493,7 @@ export type AGIStatus =
export type SalaryLineItemType =
| 'monthly_salary' | 'hourly_salary' | 'overtime' | 'bonus' | 'commission'
| 'gross_deduction_pension' | 'gross_deduction_other'
| 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_other'
| 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_bike' | 'benefit_other'
| 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus'
| 'vab' | 'parental_leave' | 'vacation'
| 'traktamente_taxfree' | 'traktamente_taxable'