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
}
}