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