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:
co-authored by
Claude Opus 4.7
parent
cd96e5ec26
commit
3fa871c742
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user