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
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 %)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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