Salary module (#245)
* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking - Added personnummer encryption and decryption functions for secure storage. - Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions. - Implemented tax table lookup functionality for calculating tax amounts based on monthly income. - Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations. - Established row-level security policies for all new tables to ensure company-scoped access. * feat: add salary calculation modules for 2026 - Implemented engångsskatt calculation for one-time payments with tax brackets. - Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings. - Created pain.001 generator for salary batch payments in compliance with Swedish banking standards. - Developed PDF template for payslips, including detailed breakdowns and employer costs. - Generated seed data for Swedish tax tables for 2026, including SQL insert statements. - Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations. - Added seed script for populating tax tables in the database. * feat: Update meal reduction percentages in traktamente calculation fix: Remove obsolete seed script for 2026 tax tables feat: Extend SalaryRunStatus type to include 'corrected' status feat: Implement KU10 XML generation endpoint for annual employee income statements feat: Add endpoint for creating corrections to booked salary runs feat: Implement endpoint for sending payslip PDFs to employees feat: Create KU10 XML generator for annual reporting feat: Add salary transaction matcher for auto-linking bank transactions to salary entries chore: Add database migration for salary correction support * feat: replace select elements with custom Select component for employment and salary types * feat: enhance salary calculations with pension entry and avgifter category support
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis'
|
||||
|
||||
/**
|
||||
* Arbetsgivaravgiftsunderlag report.
|
||||
* Monthly breakdown by avgifter rate category for AGI reconciliation.
|
||||
* Per BFL: Part of räkenskapsinformation, 7-year retention.
|
||||
*/
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString())
|
||||
|
||||
try {
|
||||
const report = await generateAvgifterBasis(supabase, companyId, year)
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Kunde inte generera avgiftsunderlag'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generateSalaryJournal } from '@/lib/reports/salary-journal'
|
||||
|
||||
/**
|
||||
* Lönejournal report — per BFNAR 2013:2 behandlingshistorik requirement.
|
||||
* Monthly/annual per-employee salary register for AGI reconciliation.
|
||||
*/
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString())
|
||||
const monthFrom = searchParams.get('month_from') ? parseInt(searchParams.get('month_from')!) : undefined
|
||||
const monthTo = searchParams.get('month_to') ? parseInt(searchParams.get('month_to')!) : undefined
|
||||
|
||||
try {
|
||||
const report = await generateSalaryJournal(supabase, companyId, year, monthFrom, monthTo)
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Kunde inte generera lönejournal'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
|
||||
|
||||
/**
|
||||
* Semesterlöneskuld report — per BFNAR 2016:10 kap 16.
|
||||
* Per-employee vacation liability (accounts 2920 + 2940).
|
||||
* Required for year-end closing.
|
||||
*/
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString())
|
||||
|
||||
try {
|
||||
const report = await generateVacationLiability(supabase, companyId, year)
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Kunde inte generera semesterlöneskuld'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
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: employee, error } = await supabase
|
||||
.from('employees')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...employee,
|
||||
personnummer: `XXXXXXXX-${employee.personnummer_last4}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
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, UpdateEmployeeSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Check employee exists
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !existing) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updates: Record<string, unknown> = { ...body }
|
||||
|
||||
// Handle personnummer update if provided
|
||||
if (body.personnummer) {
|
||||
const pnrValidation = validatePersonnummer(body.personnummer)
|
||||
if (!pnrValidation.valid) {
|
||||
return NextResponse.json({ error: pnrValidation.error }, { status: 400 })
|
||||
}
|
||||
updates.personnummer = encryptPersonnummer(body.personnummer)
|
||||
updates.personnummer_last4 = extractLast4(body.personnummer)
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('employees')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...updated,
|
||||
personnummer: `XXXXXXXX-${updated.personnummer_last4}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
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)
|
||||
|
||||
// Soft delete only — BFL 7 kap retention
|
||||
const { data, error } = await supabase
|
||||
.from('employees')
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { id: data.id, is_active: false } })
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const activeOnly = searchParams.get('active') !== 'false'
|
||||
|
||||
let query = supabase
|
||||
.from('employees')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('last_name')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Mask personnummer — only show last 4
|
||||
const masked = (data || []).map(emp => ({
|
||||
...emp,
|
||||
personnummer: `XXXXXXXX-${emp.personnummer_last4}`,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ data: masked })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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, CreateEmployeeSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Validate personnummer format + Luhn
|
||||
const pnrValidation = validatePersonnummer(body.personnummer)
|
||||
if (!pnrValidation.valid) {
|
||||
return NextResponse.json({ error: pnrValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Encrypt personnummer
|
||||
const encryptedPnr = encryptPersonnummer(body.personnummer)
|
||||
const last4 = extractLast4(body.personnummer)
|
||||
|
||||
const { data: employee, error } = await supabase
|
||||
.from('employees')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
personnummer: encryptedPnr,
|
||||
personnummer_last4: last4,
|
||||
employment_type: body.employment_type,
|
||||
employment_start: body.employment_start,
|
||||
employment_end: body.employment_end || null,
|
||||
employment_degree: body.employment_degree,
|
||||
salary_type: body.salary_type,
|
||||
monthly_salary: body.monthly_salary || null,
|
||||
hourly_rate: body.hourly_rate || null,
|
||||
tax_table_number: body.tax_table_number || null,
|
||||
tax_column: body.tax_column,
|
||||
tax_municipality: body.tax_municipality || null,
|
||||
is_sidoinkomst: body.is_sidoinkomst,
|
||||
f_skatt_status: body.f_skatt_status,
|
||||
clearing_number: body.clearing_number || null,
|
||||
bank_account_number: body.bank_account_number || null,
|
||||
vacation_rule: body.vacation_rule,
|
||||
vacation_days_per_year: body.vacation_days_per_year,
|
||||
semestertillagg_rate: body.semestertillagg_rate,
|
||||
email: body.email || null,
|
||||
phone: body.phone || null,
|
||||
address_line1: body.address_line1 || null,
|
||||
postal_code: body.postal_code || null,
|
||||
city: body.city || null,
|
||||
vaxa_stod_eligible: body.vaxa_stod_eligible,
|
||||
vaxa_stod_start: body.vaxa_stod_start || null,
|
||||
vaxa_stod_end: body.vaxa_stod_end || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...employee,
|
||||
personnummer: `XXXXXXXX-${last4}`,
|
||||
},
|
||||
}, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator'
|
||||
import type { KU10EmployeeData, KU10CompanyData } from '@/lib/salary/ku/ku10-generator'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Generate KU10 (Kontrolluppgift) XML for a calendar year.
|
||||
*
|
||||
* Per Skatteförfarandelagen 15 kap: Must be filed by January 31 of the
|
||||
* following year. Reports total annual income, tax, and benefits per employee.
|
||||
*
|
||||
* The XML is räkenskapsinformation per BFL 7 kap — 7-year retention.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ year: string }> }
|
||||
) {
|
||||
const { year } = await params
|
||||
const yearNum = parseInt(year)
|
||||
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)
|
||||
|
||||
if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) {
|
||||
return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load company
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('contact_name, contact_phone, contact_email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
// Load all booked salary run employees for the year, grouped by employee
|
||||
const { data: runEmployees, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(`
|
||||
employee_id, gross_salary, tax_withheld, avgifter_basis,
|
||||
employee:employees(personnummer, specification_number, employment_start, employment_end),
|
||||
salary_run:salary_runs!inner(period_year, status),
|
||||
line_items:salary_line_items(item_type, amount)
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
// Filter to booked runs for the year
|
||||
const bookedForYear = (runEmployees || []).filter(sre => {
|
||||
const run = sre.salary_run as unknown as { period_year: number; status: string } | null
|
||||
return run && run.period_year === yearNum && run.status === 'booked'
|
||||
})
|
||||
|
||||
// Aggregate per employee
|
||||
const byEmployee = new Map<string, {
|
||||
personnummer: string
|
||||
specificationNumber: number
|
||||
employmentStart: string | null
|
||||
employmentEnd: string | null
|
||||
totalGross: number
|
||||
totalTax: number
|
||||
totalAvgifterBasis: number
|
||||
benefitCar: number
|
||||
benefitHousing: number
|
||||
benefitMeals: number
|
||||
benefitOther: number
|
||||
}>()
|
||||
|
||||
for (const sre of bookedForYear) {
|
||||
const emp = sre.employee as unknown as { personnummer: string; specification_number: number; employment_start: string; employment_end: string | null } | null
|
||||
if (!emp) continue
|
||||
|
||||
const current = byEmployee.get(sre.employee_id) || {
|
||||
personnummer: emp.personnummer,
|
||||
specificationNumber: emp.specification_number,
|
||||
employmentStart: emp.employment_start,
|
||||
employmentEnd: emp.employment_end,
|
||||
totalGross: 0, totalTax: 0, totalAvgifterBasis: 0,
|
||||
benefitCar: 0, benefitHousing: 0, benefitMeals: 0, benefitOther: 0,
|
||||
}
|
||||
|
||||
current.totalGross += sre.gross_salary
|
||||
current.totalTax += sre.tax_withheld
|
||||
current.totalAvgifterBasis += sre.avgifter_basis
|
||||
|
||||
// Sum benefits by type from line items
|
||||
const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }>
|
||||
for (const li of lineItems) {
|
||||
if (li.item_type === 'benefit_car') current.benefitCar += li.amount
|
||||
else if (li.item_type === 'benefit_housing') current.benefitHousing += li.amount
|
||||
else if (li.item_type === 'benefit_meals') current.benefitMeals += li.amount
|
||||
else if (['benefit_wellness', 'benefit_other'].includes(li.item_type)) current.benefitOther += li.amount
|
||||
}
|
||||
|
||||
byEmployee.set(sre.employee_id, current)
|
||||
}
|
||||
|
||||
if (byEmployee.size === 0) {
|
||||
return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 })
|
||||
}
|
||||
|
||||
const companyData: KU10CompanyData = {
|
||||
orgNumber: company.org_number || '',
|
||||
companyName: company.name,
|
||||
year: yearNum,
|
||||
contactName: settings?.contact_name || company.name,
|
||||
contactPhone: settings?.contact_phone || '',
|
||||
contactEmail: settings?.contact_email || '',
|
||||
}
|
||||
|
||||
const r = (x: number) => Math.round(x * 100) / 100
|
||||
const employeeData: KU10EmployeeData[] = Array.from(byEmployee.values()).map(emp => ({
|
||||
personnummer: emp.personnummer,
|
||||
specificationNumber: emp.specificationNumber,
|
||||
totalGross: r(emp.totalGross),
|
||||
totalTax: r(emp.totalTax),
|
||||
totalAvgifterBasis: r(emp.totalAvgifterBasis),
|
||||
benefitCar: emp.benefitCar > 0 ? r(emp.benefitCar) : undefined,
|
||||
benefitHousing: emp.benefitHousing > 0 ? r(emp.benefitHousing) : undefined,
|
||||
benefitMeals: emp.benefitMeals > 0 ? r(emp.benefitMeals) : undefined,
|
||||
benefitOther: emp.benefitOther > 0 ? r(emp.benefitOther) : undefined,
|
||||
employmentStart: emp.employmentStart || undefined,
|
||||
employmentEnd: emp.employmentEnd || undefined,
|
||||
}))
|
||||
|
||||
const xml = generateKU10Xml(companyData, employeeData)
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="KU10_${company.org_number}_${yearNum}.xml"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { loadPayrollConfig } from '@/lib/salary/payroll-config'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ year: string }> }
|
||||
) {
|
||||
const { year } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const yearNum = parseInt(year)
|
||||
if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) {
|
||||
return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await loadPayrollConfig(supabase, yearNum)
|
||||
return NextResponse.json({ data: config })
|
||||
} catch {
|
||||
return NextResponse.json({ error: `Löneuppgifter för ${year} saknas` }, { status: 404 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generateAGIXml, buildIndividuppgifterSnapshot } from '@/lib/salary/agi/xml-generator'
|
||||
import type { AGIEmployeeData, AGICompanyData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Generate AGI XML for a salary run.
|
||||
*
|
||||
* Per agi-filing.md:
|
||||
* - FK570 (specifikationsnummer) MUST stay consistent per employee
|
||||
* - Corrections resubmit with same FK570 — different number = new record
|
||||
* - XML is räkenskapsinformation, stored for 7-year retention per BFL 7 kap
|
||||
* - Filing deadline: 12th of following month (17th in Jan/Aug for ≤40 MSEK)
|
||||
*/
|
||||
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)
|
||||
|
||||
// Load salary run
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
|
||||
return NextResponse.json({ error: 'AGI kan bara genereras efter granskning' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load company
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load company settings for contact info
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('contact_name, contact_phone, contact_email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
// Load employees with their data
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (!runEmployees || runEmployees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build AGI data
|
||||
const companyData: AGICompanyData = {
|
||||
orgNumber: company.org_number || '',
|
||||
companyName: company.name,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
contactName: settings?.contact_name || company.name,
|
||||
contactPhone: settings?.contact_phone || '',
|
||||
contactEmail: settings?.contact_email || '',
|
||||
}
|
||||
|
||||
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
|
||||
const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null
|
||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
|
||||
// Sum benefits by type for AGI rutor 012-019
|
||||
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
|
||||
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
|
||||
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
|
||||
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
|
||||
|
||||
return {
|
||||
personnummer: emp?.personnummer || '',
|
||||
specificationNumber: emp?.specification_number || 0,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
avgifterBasis: sre.avgifter_basis,
|
||||
fSkattPayment: emp?.f_skatt_status === 'f_skatt' ? sre.gross_salary : undefined,
|
||||
benefitCar: benefitCar > 0 ? benefitCar : undefined,
|
||||
benefitHousing: benefitHousing > 0 ? benefitHousing : undefined,
|
||||
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
|
||||
benefitOther: benefitOther > 0 ? benefitOther : undefined,
|
||||
sickDays: sre.sick_days > 0 ? sre.sick_days : undefined,
|
||||
vabDays: sre.vab_days > 0 ? sre.vab_days : undefined,
|
||||
parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// Build totals with avgifter breakdown by category (read from DB, not re-derived from rate)
|
||||
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
||||
for (const sre of runEmployees) {
|
||||
const dbCategory = sre.avgifter_category as string | null
|
||||
// Map DB category to AGI HU category; fall back to rate heuristic for legacy runs without stored category
|
||||
const category = dbCategory
|
||||
? (dbCategory === 'reduced_65plus' ? 'reduced65plus' : dbCategory === 'vaxa_stod' ? 'standard' : dbCategory)
|
||||
: (sre.avgifter_rate <= 0.1022 ? 'reduced65plus' : sre.avgifter_rate <= 0.2082 ? 'youth' : 'standard')
|
||||
const cat = avgifterByCategory[category as keyof typeof avgifterByCategory] || { basis: 0, amount: 0 }
|
||||
cat.basis += sre.avgifter_basis
|
||||
cat.amount += sre.avgifter_amount
|
||||
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
|
||||
}
|
||||
|
||||
const totals: AGITotals = {
|
||||
totalTax: run.total_tax,
|
||||
totalAvgifterBasis: runEmployees.reduce((s, e) => s + e.avgifter_basis, 0),
|
||||
avgifterByCategory,
|
||||
}
|
||||
|
||||
// Check for existing AGI for correction flag
|
||||
const { data: existingAgi } = await supabase
|
||||
.from('agi_declarations')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('period_year', run.period_year)
|
||||
.eq('period_month', run.period_month)
|
||||
.single()
|
||||
|
||||
const isCorrection = !!existingAgi
|
||||
|
||||
const xml = generateAGIXml(companyData, employeeData, totals, isCorrection)
|
||||
const individuppgifter = buildIndividuppgifterSnapshot(employeeData)
|
||||
|
||||
// Store AGI declaration (upsert for corrections per unique constraint)
|
||||
if (existingAgi) {
|
||||
await supabase
|
||||
.from('agi_declarations')
|
||||
.update({
|
||||
xml_content: xml,
|
||||
individuppgifter,
|
||||
total_gross: run.total_gross,
|
||||
total_tax: run.total_tax,
|
||||
total_avgifter_basis: totals.totalAvgifterBasis,
|
||||
total_avgifter: run.total_avgifter,
|
||||
employee_count: employeeData.length,
|
||||
is_correction: true,
|
||||
corrects_agi_id: existingAgi.id,
|
||||
salary_run_id: run.id,
|
||||
})
|
||||
.eq('id', existingAgi.id)
|
||||
} else {
|
||||
await supabase
|
||||
.from('agi_declarations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
salary_run_id: run.id,
|
||||
period_year: run.period_year,
|
||||
period_month: run.period_month,
|
||||
xml_content: xml,
|
||||
individuppgifter,
|
||||
total_gross: run.total_gross,
|
||||
total_tax: run.total_tax,
|
||||
total_avgifter_basis: totals.totalAvgifterBasis,
|
||||
total_avgifter: run.total_avgifter,
|
||||
employee_count: employeeData.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Update salary run
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
.update({ agi_generated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'agi.generated',
|
||||
payload: {
|
||||
agiId: existingAgi?.id || 'new',
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-complete arbetsgivardeklaration deadline for this period
|
||||
// Per Skatteförfarandelagen: AGI generation satisfies the filing obligation
|
||||
const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString(),
|
||||
completed_by: user.id,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('type', 'arbetsgivardeklaration')
|
||||
.eq('period', period)
|
||||
.eq('status', 'pending')
|
||||
|
||||
// Return as downloadable XML
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="AGI_${company.org_number}_${run.period_year}${String(run.period_month).padStart(2, '0')}.xml"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function sumLineItemAmounts(lineItems: Array<Record<string, unknown>>, types: string[]): number {
|
||||
return lineItems
|
||||
.filter(li => types.includes(li.item_type as string))
|
||||
.reduce((sum, li) => sum + ((li.amount as number) || 0), 0)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** review → approved (authorization recorded) */
|
||||
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 { data: run, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
status: 'approved',
|
||||
approved_by: user.id,
|
||||
approved_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'review')
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'salary_run.approved',
|
||||
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: run })
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** paid → booked (creates immutable journal entries) */
|
||||
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)
|
||||
|
||||
// Verify run is paid
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'paid')
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara markerad som betald' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load employees with line items
|
||||
const { data: employees, error: empError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (empError || !employees || employees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
{
|
||||
id: run.id,
|
||||
period_year: run.period_year,
|
||||
period_month: run.period_month,
|
||||
payment_date: run.payment_date,
|
||||
voucher_series: run.voucher_series,
|
||||
total_gross: run.total_gross,
|
||||
total_tax: run.total_tax,
|
||||
total_net: run.total_net,
|
||||
total_avgifter: run.total_avgifter,
|
||||
total_vacation_accrual: run.total_vacation_accrual,
|
||||
employees: employees.map(sre => ({
|
||||
employee_id: sre.employee_id,
|
||||
employment_type: sre.employee?.employment_type || 'employee',
|
||||
gross_salary: sre.gross_salary,
|
||||
tax_withheld: sre.tax_withheld,
|
||||
net_salary: sre.net_salary,
|
||||
avgifter_amount: sre.avgifter_amount,
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
line_items: (sre.line_items || []).map((li: Record<string, unknown>) => ({
|
||||
item_type: li.item_type as string,
|
||||
amount: li.amount as number,
|
||||
account_number: li.account_number as string | null,
|
||||
is_net_deduction: li.is_net_deduction as boolean,
|
||||
is_gross_deduction: li.is_gross_deduction as boolean,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
)
|
||||
|
||||
// Update run with journal entry references
|
||||
const entryIds = [salaryEntry.id, avgifterEntry.id]
|
||||
const updates: Record<string, unknown> = {
|
||||
status: 'booked',
|
||||
salary_entry_id: salaryEntry.id,
|
||||
avgifter_entry_id: avgifterEntry.id,
|
||||
booked_at: new Date().toISOString(),
|
||||
booked_by: user.id,
|
||||
}
|
||||
if (vacationEntry) {
|
||||
updates.vacation_entry_id = vacationEntry.id
|
||||
entryIds.push(vacationEntry.id)
|
||||
}
|
||||
if (pensionEntry) {
|
||||
updates.pension_entry_id = pensionEntry.id
|
||||
entryIds.push(pensionEntry.id)
|
||||
}
|
||||
|
||||
const { data: bookedRun, error: updateError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'salary_run.booked',
|
||||
payload: { salaryRunId: id, entryIds, userId: user.id, companyId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: bookedRun })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Bokföring misslyckades'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { calculateSalary } from '@/lib/salary/calculation-engine'
|
||||
import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config'
|
||||
import { fetchAllTaxTableRatesForRun } from '@/lib/salary/tax-tables'
|
||||
import type { SalaryLineItemType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
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)
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json({ error: 'Kan bara beräkna utkast' }, { status: 400 })
|
||||
}
|
||||
|
||||
const paymentYear = parseInt(run.payment_date.split('-')[0])
|
||||
|
||||
// Load config
|
||||
const config = await loadPayrollConfig(supabase, paymentYear)
|
||||
|
||||
// Load all employees in this run
|
||||
const { data: runEmployees, error: empError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(*), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (empError || !runEmployees || runEmployees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch tax table rates from Skatteverket API for all needed tables/columns
|
||||
const tableNumbers = [...new Set(runEmployees.filter(e => e.employee?.tax_table_number).map(e => e.employee.tax_table_number as number))]
|
||||
const columns = [...new Set(runEmployees.filter(e => e.employee?.tax_column).map(e => e.employee.tax_column as number))]
|
||||
const taxRates = tableNumbers.length > 0
|
||||
? await fetchAllTaxTableRatesForRun(paymentYear, tableNumbers, columns.length > 0 ? columns : [1])
|
||||
: []
|
||||
|
||||
let totalGross = 0
|
||||
let totalTax = 0
|
||||
let totalNet = 0
|
||||
let totalAvgifter = 0
|
||||
let totalVacationAccrual = 0
|
||||
let totalEmployerCost = 0
|
||||
|
||||
// Load YTD data from prior booked salary runs this year (filters pushed to DB)
|
||||
const { data: priorRuns } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('salary_run.period_year', run.period_year)
|
||||
.eq('salary_run.status', 'booked')
|
||||
.lt('salary_run.period_month', run.period_month)
|
||||
|
||||
const ytdByEmployee = new Map<string, { gross: number; tax: number; net: number }>()
|
||||
for (const prior of (priorRuns || [])) {
|
||||
const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 }
|
||||
current.gross += prior.gross_salary
|
||||
current.tax += prior.tax_withheld
|
||||
current.net += prior.net_salary
|
||||
ytdByEmployee.set(prior.employee_id, current)
|
||||
}
|
||||
|
||||
for (const sre of runEmployees) {
|
||||
const emp = sre.employee
|
||||
if (!emp) continue
|
||||
|
||||
const lineItems = (sre.line_items || []).map((li: Record<string, unknown>) => ({
|
||||
itemType: li.item_type as SalaryLineItemType,
|
||||
amount: li.amount as number,
|
||||
isTaxable: li.is_taxable as boolean,
|
||||
isAvgiftBasis: li.is_avgift_basis as boolean,
|
||||
isVacationBasis: li.is_vacation_basis as boolean,
|
||||
isGrossDeduction: li.is_gross_deduction as boolean,
|
||||
isNetDeduction: li.is_net_deduction as boolean,
|
||||
}))
|
||||
|
||||
const result = calculateSalary(
|
||||
{
|
||||
employmentType: emp.employment_type,
|
||||
salaryType: emp.salary_type,
|
||||
monthlySalary: emp.monthly_salary || 0,
|
||||
hourlyRate: emp.hourly_rate || undefined,
|
||||
hoursWorked: sre.hours_worked || undefined,
|
||||
employmentDegree: emp.employment_degree,
|
||||
taxTableNumber: emp.tax_table_number,
|
||||
taxColumn: emp.tax_column || 1,
|
||||
isSidoinkomst: emp.is_sidoinkomst,
|
||||
jamkningPercentage: emp.jamkning_percentage,
|
||||
jamkningValidFrom: emp.jamkning_valid_from,
|
||||
jamkningValidTo: emp.jamkning_valid_to,
|
||||
fSkattStatus: emp.f_skatt_status,
|
||||
personnummer: emp.personnummer,
|
||||
paymentDate: run.payment_date,
|
||||
vacationRule: emp.vacation_rule,
|
||||
vacationDaysPerYear: emp.vacation_days_per_year,
|
||||
semestertillaggRate: emp.semestertillagg_rate,
|
||||
vaxaStodEligible: emp.vaxa_stod_eligible,
|
||||
vaxaStodStart: emp.vaxa_stod_start,
|
||||
vaxaStodEnd: emp.vaxa_stod_end,
|
||||
lineItems,
|
||||
},
|
||||
config,
|
||||
taxRates.map(r => ({
|
||||
tableYear: r.tableYear,
|
||||
tableNumber: r.tableNumber,
|
||||
columnNumber: r.columnNumber,
|
||||
incomeFrom: r.incomeFrom,
|
||||
incomeTo: r.incomeTo,
|
||||
taxAmount: r.taxAmount,
|
||||
}))
|
||||
)
|
||||
|
||||
// Count absence days from line items
|
||||
const rawLines = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
function sumQuantity(types: string[]): number {
|
||||
return rawLines
|
||||
.filter(li => types.includes(li.item_type as string))
|
||||
.reduce((sum: number, li) => sum + ((li.quantity as number) || 0), 0)
|
||||
}
|
||||
const sickDays = sumQuantity(['sick_karens', 'sick_day2_14'])
|
||||
const vabDays = sumQuantity(['vab'])
|
||||
const parentalDays = sumQuantity(['parental_leave'])
|
||||
const vacationDays = sumQuantity(['vacation'])
|
||||
|
||||
// Update salary_run_employee with calculated results
|
||||
await supabase
|
||||
.from('salary_run_employees')
|
||||
.update({
|
||||
gross_salary: result.grossSalary,
|
||||
gross_deductions: result.grossDeductions,
|
||||
benefit_values: result.benefitValues,
|
||||
taxable_income: result.taxableIncome,
|
||||
tax_withheld: result.taxWithheld,
|
||||
net_deductions: result.netDeductions,
|
||||
net_salary: result.netSalary,
|
||||
avgifter_rate: result.avgifterRate,
|
||||
avgifter_amount: result.avgifterAmount,
|
||||
avgifter_basis: result.avgifterBasis,
|
||||
avgifter_category: result.avgifterCategory,
|
||||
vacation_accrual: result.vacationAccrual,
|
||||
vacation_accrual_avgifter: result.vacationAccrualAvgifter,
|
||||
tax_table_number: emp.tax_table_number,
|
||||
tax_column: emp.tax_column,
|
||||
tax_table_year: paymentYear,
|
||||
sick_days: sickDays,
|
||||
vab_days: vabDays,
|
||||
parental_days: parentalDays,
|
||||
vacation_days_taken: vacationDays,
|
||||
calculation_breakdown: { steps: result.steps },
|
||||
ytd_gross: Math.round(((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100) / 100,
|
||||
ytd_tax: Math.round(((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100) / 100,
|
||||
ytd_net: Math.round(((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100) / 100,
|
||||
})
|
||||
.eq('id', sre.id)
|
||||
|
||||
totalGross += result.grossSalary
|
||||
totalTax += result.taxWithheld
|
||||
totalNet += result.netSalary
|
||||
totalAvgifter += result.avgifterAmount
|
||||
totalVacationAccrual += result.vacationAccrual
|
||||
totalEmployerCost += result.totalEmployerCost
|
||||
}
|
||||
|
||||
// Update run totals
|
||||
const { data: updatedRun, error: updateError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
total_gross: Math.round(totalGross * 100) / 100,
|
||||
total_tax: Math.round(totalTax * 100) / 100,
|
||||
total_net: Math.round(totalNet * 100) / 100,
|
||||
total_avgifter: Math.round(totalAvgifter * 100) / 100,
|
||||
total_vacation_accrual: Math.round(totalVacationAccrual * 100) / 100,
|
||||
total_employer_cost: Math.round(totalEmployerCost * 100) / 100,
|
||||
calculation_params: serializePayrollConfig(config),
|
||||
})
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updatedRun })
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Create a correction for a booked salary run.
|
||||
*
|
||||
* Per BFL 5 kap 5§ (Rättelse): Corrections must preserve the original.
|
||||
* This is implemented as:
|
||||
* 1. Reverse (storno) all journal entries from the original run
|
||||
* 2. Create a new correction salary run for the same period
|
||||
* 3. Mark the original run as 'corrected'
|
||||
*
|
||||
* The new run starts in 'draft' status so the user can edit and re-calculate.
|
||||
* On booking the correction run, new correct entries are created.
|
||||
* Both original and correction are visible in the journal per BFL.
|
||||
*
|
||||
* AGI must be re-generated with same FK570 (correction flag) per agi-filing.md.
|
||||
*/
|
||||
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)
|
||||
|
||||
// Load the original booked run
|
||||
const { data: originalRun, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'booked')
|
||||
.single()
|
||||
|
||||
if (runError || !originalRun) {
|
||||
return NextResponse.json({ error: 'Kan bara korrigera bokförda lönekörningar' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Reverse all journal entries from the original run (storno per BFL 5 kap 5§)
|
||||
const entryIds = [
|
||||
originalRun.salary_entry_id,
|
||||
originalRun.avgifter_entry_id,
|
||||
originalRun.vacation_entry_id,
|
||||
originalRun.pension_entry_id,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
for (const entryId of entryIds) {
|
||||
try {
|
||||
await reverseEntry(supabase, companyId, user.id, entryId)
|
||||
} catch (err) {
|
||||
// Entry may already be reversed — continue
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (!msg.includes('already reversed')) {
|
||||
return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark original as corrected
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
.update({ status: 'corrected' })
|
||||
.eq('id', id)
|
||||
|
||||
// Create new correction run for same period
|
||||
// Remove the unique constraint conflict by using the original run's unique key
|
||||
// The unique constraint is (company_id, period_year, period_month) so we need
|
||||
// to delete the uniqueness or handle it. Since original is now 'corrected',
|
||||
// and we want a new run for the same period, we update the unique constraint.
|
||||
// Actually the DB still enforces uniqueness. The correction run needs the same period.
|
||||
// Solution: drop the old unique index and add a partial one excluding corrected runs,
|
||||
// OR just use the same run ID pattern. Let's create the correction run and handle the conflict.
|
||||
|
||||
const { data: correctionRun, error: createError } = await supabase
|
||||
.from('salary_runs')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
period_year: originalRun.period_year,
|
||||
period_month: originalRun.period_month,
|
||||
payment_date: originalRun.payment_date,
|
||||
voucher_series: originalRun.voucher_series,
|
||||
is_correction: true,
|
||||
corrects_run_id: originalRun.id,
|
||||
notes: `Korrigering av lönekörning ${originalRun.period_year}-${String(originalRun.period_month).padStart(2, '0')}`,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (createError) {
|
||||
// If unique constraint violation, the period already has an active run
|
||||
if (createError.code === '23505') {
|
||||
return NextResponse.json({
|
||||
error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.',
|
||||
}, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: createError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Copy employees from original run to correction run (with snapshots)
|
||||
const { data: originalEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
for (const origEmp of originalEmployees || []) {
|
||||
const { data: newSre } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.insert({
|
||||
salary_run_id: correctionRun.id,
|
||||
employee_id: origEmp.employee_id,
|
||||
company_id: companyId,
|
||||
employment_degree: origEmp.employment_degree,
|
||||
monthly_salary: origEmp.monthly_salary,
|
||||
salary_type: origEmp.salary_type,
|
||||
hours_worked: origEmp.hours_worked,
|
||||
tax_table_number: origEmp.tax_table_number,
|
||||
tax_column: origEmp.tax_column,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (newSre) {
|
||||
// Copy line items
|
||||
const lineItems = (origEmp.line_items || []) as Array<Record<string, unknown>>
|
||||
for (const li of lineItems) {
|
||||
await supabase.from('salary_line_items').insert({
|
||||
salary_run_employee_id: newSre.id,
|
||||
company_id: companyId,
|
||||
item_type: li.item_type,
|
||||
description: li.description,
|
||||
quantity: li.quantity,
|
||||
unit_price: li.unit_price,
|
||||
amount: li.amount,
|
||||
is_taxable: li.is_taxable,
|
||||
is_avgift_basis: li.is_avgift_basis,
|
||||
is_vacation_basis: li.is_vacation_basis,
|
||||
is_gross_deduction: li.is_gross_deduction,
|
||||
is_net_deduction: li.is_net_deduction,
|
||||
account_number: li.account_number,
|
||||
sort_order: li.sort_order,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: correctionRun,
|
||||
message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.',
|
||||
reversed_entry_count: entryIds.length,
|
||||
}, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** Remove employee from a draft salary run. Cascades to delete their line items. */
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; employeeId: string }> }
|
||||
) {
|
||||
const { id, employeeId } = 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)
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
|
||||
// Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE)
|
||||
const { error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.delete()
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import type { SalaryLineItemType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
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, AddEmployeeToRunSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify employee exists and is active
|
||||
const { data: employee, error: empError } = await supabase
|
||||
.from('employees')
|
||||
.select('*')
|
||||
.eq('id', body.employee_id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.single()
|
||||
|
||||
if (empError || !employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if already added
|
||||
const { data: existing } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('id')
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', body.employee_id)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Snapshot employee data
|
||||
const { data: sre, error: sreError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.insert({
|
||||
salary_run_id: id,
|
||||
employee_id: employee.id,
|
||||
company_id: companyId,
|
||||
employment_degree: employee.employment_degree,
|
||||
monthly_salary: employee.monthly_salary || 0,
|
||||
salary_type: employee.salary_type,
|
||||
hours_worked: body.hours_worked || null,
|
||||
tax_table_number: employee.tax_table_number,
|
||||
tax_column: employee.tax_column,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sreError) {
|
||||
return NextResponse.json({ error: sreError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Auto-create base salary line item
|
||||
const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
|
||||
let baseAmount: number
|
||||
if (employee.salary_type === 'monthly') {
|
||||
baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100
|
||||
} else {
|
||||
baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: baseSalaryType,
|
||||
description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
|
||||
quantity: employee.salary_type === 'hourly' ? body.hours_worked : null,
|
||||
unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null,
|
||||
amount: baseAmount,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
account_number: getLineItemAccount(baseSalaryType, employee.employment_type),
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: sre }, { status: 201 })
|
||||
}
|
||||
@@ -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 { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; lineId: string }> }
|
||||
) {
|
||||
const { id, lineId } = 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)
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
|
||||
const validation = await validateBody(request, UpdateSalaryLineItemSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Round amount if provided
|
||||
const updates = { ...body }
|
||||
if (updates.amount !== undefined) {
|
||||
updates.amount = Math.round(updates.amount * 100) / 100
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('salary_line_items')
|
||||
.update(updates)
|
||||
.eq('id', lineId)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !updated) {
|
||||
return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; lineId: string }> }
|
||||
) {
|
||||
const { id, lineId } = 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)
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
|
||||
const { error } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('id', lineId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
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, CreateSalaryLineItemSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify salary_run_employee belongs to this run
|
||||
const { data: sre } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('id, employee_id')
|
||||
.eq('id', body.salary_run_employee_id)
|
||||
.eq('salary_run_id', id)
|
||||
.single()
|
||||
|
||||
if (!sre) {
|
||||
return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Auto-resolve account if not provided
|
||||
const accountNumber = body.account_number || getLineItemAccount(body.item_type as never)
|
||||
|
||||
const { data: lineItem, error } = await supabase
|
||||
.from('salary_line_items')
|
||||
.insert({
|
||||
salary_run_employee_id: body.salary_run_employee_id,
|
||||
company_id: companyId,
|
||||
item_type: body.item_type,
|
||||
description: body.description,
|
||||
quantity: body.quantity || null,
|
||||
unit_price: body.unit_price || null,
|
||||
amount: Math.round(body.amount * 100) / 100,
|
||||
is_taxable: body.is_taxable,
|
||||
is_avgift_basis: body.is_avgift_basis,
|
||||
is_vacation_basis: body.is_vacation_basis,
|
||||
is_gross_deduction: body.is_gross_deduction,
|
||||
is_net_deduction: body.is_net_deduction,
|
||||
account_number: accountNumber,
|
||||
sort_order: body.sort_order,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: lineItem }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** approved → paid (payment confirmation) */
|
||||
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 { data: run, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'approved')
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara godkänd' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: run })
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
|
||||
import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Generate pain.001 (ISO 20022) payment file for a salary run.
|
||||
*
|
||||
* Per BFL: The payment file is räkenskapsinformation/underlag linked to
|
||||
* the salary journal entry. Subject to 7-year retention.
|
||||
*
|
||||
* The file is uploaded to the bank's corporate portal for batch payment.
|
||||
*/
|
||||
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)
|
||||
|
||||
// Load salary run
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!['approved', 'paid', 'booked'].includes(run.status)) {
|
||||
return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load company + settings
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bank_iban, bank_bic')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!settings?.bank_iban || !settings?.bank_bic) {
|
||||
return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load employees
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (!runEmployees || runEmployees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate all employees have bank accounts
|
||||
const missingBank = runEmployees.filter(sre => {
|
||||
const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null
|
||||
return !emp?.clearing_number || !emp?.bank_account_number
|
||||
})
|
||||
|
||||
if (missingBank.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: `${missingBank.length} anställd(a) saknar bankkontouppgifter`,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const companyData: Pain001CompanyData = {
|
||||
name: company.name,
|
||||
orgNumber: company.org_number || '',
|
||||
iban: settings.bank_iban,
|
||||
bic: settings.bank_bic,
|
||||
}
|
||||
|
||||
const employees: Pain001Employee[] = runEmployees
|
||||
.filter(sre => sre.net_salary > 0)
|
||||
.map(sre => {
|
||||
const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string }
|
||||
return {
|
||||
name: `${emp.first_name} ${emp.last_name}`,
|
||||
clearingNumber: emp.clearing_number,
|
||||
bankAccountNumber: emp.bank_account_number,
|
||||
netSalary: sre.net_salary,
|
||||
}
|
||||
})
|
||||
|
||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
const messageId = `GNUBOK-${company.org_number?.replace('-', '')}-${periodLabel}`
|
||||
|
||||
const xml = generatePain001(companyData, employees, {
|
||||
messageId,
|
||||
paymentDate: run.payment_date,
|
||||
periodLabel,
|
||||
})
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="pain001_lon_${periodLabel}.xml"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
|
||||
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
|
||||
import { maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Generate pay slip PDF for a specific employee in a salary run.
|
||||
*
|
||||
* Per BFL: Pay slips are räkenskapsinformation/underlag linked to
|
||||
* posted journal entries. Subject to 7-year retention per BFL 7 kap.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; employeeId: string }> }
|
||||
) {
|
||||
const { id, employeeId } = 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)
|
||||
|
||||
// Load salary run
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load salary run employee
|
||||
const { data: sre } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', employeeId)
|
||||
.single()
|
||||
|
||||
if (!sre) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load company
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const emp = sre.employee as {
|
||||
first_name: string; last_name: string; personnummer_last4: string;
|
||||
employment_type: string; tax_table_number: number | null; tax_column: number;
|
||||
clearing_number: string | null; bank_account_number: string | null;
|
||||
}
|
||||
|
||||
const EMPLOYMENT_LABELS: Record<string, string> = {
|
||||
employee: 'Anställd',
|
||||
company_owner: 'Företagsledare',
|
||||
board_member: 'Styrelseledamot',
|
||||
}
|
||||
|
||||
// Build line items for PDF
|
||||
const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array<Record<string, unknown>>)
|
||||
.sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0))
|
||||
.map(li => ({
|
||||
description: li.description as string,
|
||||
quantity: li.quantity as number | undefined,
|
||||
unitPrice: li.unit_price as number | undefined,
|
||||
amount: li.amount as number,
|
||||
}))
|
||||
|
||||
// Build tax reference string
|
||||
let taxReference = 'Schablon 30%'
|
||||
if (emp.tax_table_number) {
|
||||
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
|
||||
}
|
||||
|
||||
// Build breakdown steps from calculation_breakdown
|
||||
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> } | null
|
||||
const breakdownSteps = breakdown?.steps
|
||||
|
||||
// Build bank account display (masked)
|
||||
let bankAccount: string | undefined
|
||||
if (emp.clearing_number && emp.bank_account_number) {
|
||||
const lastDigits = emp.bank_account_number.slice(-4)
|
||||
bankAccount = `${emp.clearing_number}-****${lastDigits}`
|
||||
}
|
||||
|
||||
const data: PayslipData = {
|
||||
companyName: company.name,
|
||||
companyOrgNumber: company.org_number || '',
|
||||
employeeName: `${emp.first_name} ${emp.last_name}`,
|
||||
personnummerMasked: maskPersonnummer(emp.personnummer_last4),
|
||||
employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
paymentDate: run.payment_date,
|
||||
lineItems,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
taxReference,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
ytdGross: sre.ytd_gross,
|
||||
ytdTax: sre.ytd_tax,
|
||||
ytdNet: sre.ytd_net,
|
||||
bankAccount,
|
||||
breakdownSteps,
|
||||
}
|
||||
|
||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
const fileName = `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf`
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const buffer = await renderToBuffer(PayslipPDF({ data }) as any)
|
||||
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
|
||||
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
|
||||
import { maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Send pay slip PDFs to all employees with email addresses.
|
||||
*
|
||||
* Uses the existing email extension (Resend) for delivery.
|
||||
* Per BFL 7 kap: Delivery confirmation retained as part of audit trail.
|
||||
*/
|
||||
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 emailService = getEmailService()
|
||||
|
||||
// Load salary run
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (!['approved', 'paid', 'booked'].includes(run.status)) {
|
||||
return NextResponse.json({ error: 'Lönespecifikationer kan bara skickas efter godkännande' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load company
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||
|
||||
// Load employees with line items
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (!runEmployees || runEmployees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
const MONTH_NAMES = ['januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december']
|
||||
const monthName = MONTH_NAMES[run.period_month - 1]
|
||||
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const sre of runEmployees) {
|
||||
const emp = sre.employee as {
|
||||
first_name: string; last_name: string; personnummer_last4: string;
|
||||
employment_type: string; email: string | null; tax_table_number: number | null;
|
||||
tax_column: number; clearing_number: string | null; bank_account_number: string | null;
|
||||
} | null
|
||||
|
||||
if (!emp?.email) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Build payslip data
|
||||
const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array<Record<string, unknown>>)
|
||||
.sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0))
|
||||
.map(li => ({
|
||||
description: li.description as string,
|
||||
quantity: li.quantity as number | undefined,
|
||||
unitPrice: li.unit_price as number | undefined,
|
||||
amount: li.amount as number,
|
||||
}))
|
||||
|
||||
let taxReference = 'Schablon 30%'
|
||||
if (emp.tax_table_number) {
|
||||
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
|
||||
}
|
||||
|
||||
const data: PayslipData = {
|
||||
companyName: company.name,
|
||||
companyOrgNumber: company.org_number || '',
|
||||
employeeName: `${emp.first_name} ${emp.last_name}`,
|
||||
personnummerMasked: maskPersonnummer(emp.personnummer_last4),
|
||||
employmentType: emp.employment_type,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
paymentDate: run.payment_date,
|
||||
lineItems,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
taxReference,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
ytdGross: sre.ytd_gross,
|
||||
ytdTax: sre.ytd_tax,
|
||||
ytdNet: sre.ytd_net,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any)
|
||||
|
||||
await emailService.sendEmail({
|
||||
to: emp.email,
|
||||
subject: `Lönespecifikation ${monthName} ${run.period_year} — ${company.name}`,
|
||||
html: `<p>Hej ${emp.first_name},</p>
|
||||
<p>Bifogat finner du din lönespecifikation för ${monthName} ${run.period_year}.</p>
|
||||
<p>Utbetalningsdag: ${run.payment_date}</p>
|
||||
<p>Med vänliga hälsningar,<br>${company.name}</p>`,
|
||||
text: `Hej ${emp.first_name},\n\nBifogat finner du din lönespecifikation för ${monthName} ${run.period_year}.\n\nUtbetalningsdag: ${run.payment_date}\n\nMed vänliga hälsningar,\n${company.name}`,
|
||||
attachments: [{
|
||||
filename: `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf`,
|
||||
content: Buffer.from(pdfBuffer),
|
||||
}],
|
||||
})
|
||||
|
||||
sent++
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Okänt fel'
|
||||
errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
sent,
|
||||
skipped,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
total: runEmployees.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Preview the journal entries that would be created when booking this salary run.
|
||||
* Shows exact BAS accounts and amounts — this is a key differentiator.
|
||||
*/
|
||||
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: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load employees with line items
|
||||
const { data: employees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (!employees || employees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga beräknade resultat — kör beräkning först' }, { status: 400 })
|
||||
}
|
||||
|
||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
const desc = `Lön ${periodLabel}`
|
||||
|
||||
// Build salary entry preview
|
||||
const salaryLines: CreateJournalEntryLineInput[] = []
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
|
||||
for (const sre of employees) {
|
||||
for (const li of sre.line_items || []) {
|
||||
if (li.is_net_deduction || li.is_gross_deduction) continue
|
||||
const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee')
|
||||
expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [account, amount] of expenseByAccount) {
|
||||
if (amount === 0) continue
|
||||
salaryLines.push({
|
||||
account_number: account,
|
||||
debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0,
|
||||
credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0,
|
||||
line_description: `${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0)
|
||||
if (totalTax > 0) {
|
||||
salaryLines.push({
|
||||
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalTax * 100) / 100,
|
||||
line_description: `${desc} — Personalskatt`,
|
||||
})
|
||||
}
|
||||
|
||||
const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0)
|
||||
if (totalNet > 0) {
|
||||
salaryLines.push({
|
||||
account_number: SALARY_ACCOUNTS.BANK,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalNet * 100) / 100,
|
||||
line_description: `${desc} — Nettolön`,
|
||||
})
|
||||
}
|
||||
|
||||
// Build avgifter entry preview
|
||||
const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0)
|
||||
const avgifterLines: CreateJournalEntryLineInput[] = [
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
|
||||
debit_amount: Math.round(totalAvgifter * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Arbetsgivaravgifter`,
|
||||
},
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalAvgifter * 100) / 100,
|
||||
line_description: `${desc} — Arbetsgivaravgifter`,
|
||||
},
|
||||
]
|
||||
|
||||
// Build vacation entry preview
|
||||
const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
|
||||
const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
|
||||
const vacationLines: CreateJournalEntryLineInput[] = []
|
||||
if (totalVacation > 0) {
|
||||
vacationLines.push(
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
|
||||
debit_amount: Math.round(totalVacation * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Semesteravsättning`,
|
||||
},
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalVacation * 100) / 100,
|
||||
line_description: `${desc} — Semesteravsättning`,
|
||||
}
|
||||
)
|
||||
}
|
||||
if (totalVacationAvgifter > 0) {
|
||||
vacationLines.push(
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
|
||||
debit_amount: Math.round(totalVacationAvgifter * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Sociala avgifter semester`,
|
||||
},
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalVacationAvgifter * 100) / 100,
|
||||
line_description: `${desc} — Sociala avgifter semester`,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Build pension entry preview (löneväxling — per deductions-lonevaxling.md)
|
||||
// This would be populated from salary_line_items with type 'gross_deduction_pension'
|
||||
// For now, pension preview is shown when pension line items exist
|
||||
const pensionLineItems = employees.flatMap(e =>
|
||||
((e.line_items || []) as Array<Record<string, unknown>>)
|
||||
.filter(li => li.item_type === 'gross_deduction_pension')
|
||||
)
|
||||
const pensionLines: CreateJournalEntryLineInput[] = []
|
||||
if (pensionLineItems.length > 0) {
|
||||
const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0))
|
||||
const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100
|
||||
const slp = Math.round(pensionContribution * 0.2426 * 100) / 100
|
||||
if (pensionContribution > 0) {
|
||||
pensionLines.push(
|
||||
{ account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc} — Pensionsförsäkringspremier` },
|
||||
{ account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc} — Pensionsförsäkringspremier` },
|
||||
)
|
||||
if (slp > 0) {
|
||||
pensionLines.push(
|
||||
{ account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc} — Särskild löneskatt 24,26%` },
|
||||
{ account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc} — Särskild löneskatt 24,26%` },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
salaryEntry: {
|
||||
description: desc,
|
||||
lines: salaryLines,
|
||||
},
|
||||
avgifterEntry: {
|
||||
description: `${desc} — Arbetsgivaravgifter`,
|
||||
lines: avgifterLines,
|
||||
},
|
||||
vacationEntry: vacationLines.length > 0 ? {
|
||||
description: `${desc} — Semesteravsättning`,
|
||||
lines: vacationLines,
|
||||
} : null,
|
||||
pensionEntry: pensionLines.length > 0 ? {
|
||||
description: `${desc} — Pensionsavsättning`,
|
||||
lines: pensionLines,
|
||||
} : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** review → draft (unlock for editing) */
|
||||
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 { data: run, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({ status: 'draft' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'review')
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: run })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* draft → review (freeze calculations)
|
||||
*
|
||||
* Per f-skatt.md: Employer must verify F-skatt status before first payment.
|
||||
* If any employee has f_skatt_status = 'not_verified', return a warning.
|
||||
* The user can still proceed but the warning is logged for audit trail.
|
||||
*/
|
||||
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)
|
||||
|
||||
// Check for F-skatt verification warnings
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('employee:employees(first_name, last_name, f_skatt_status)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
const warnings: string[] = []
|
||||
for (const sre of runEmployees || []) {
|
||||
const emp = sre.employee as unknown as { first_name: string; last_name: string; f_skatt_status: string } | null
|
||||
if (emp?.f_skatt_status === 'not_verified') {
|
||||
warnings.push(
|
||||
`${emp.first_name} ${emp.last_name}: F-skatt ej verifierad — 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { data: run, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({ status: 'review' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara i utkaststatus' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: run,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
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: run, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load employees with line items
|
||||
const { data: employees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(id, first_name, last_name, personnummer_last4, employment_type), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
.order('created_at')
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...run,
|
||||
employees: (employees || []).map(emp => ({
|
||||
...emp,
|
||||
employee: emp.employee ? {
|
||||
...emp.employee,
|
||||
personnummer: `XXXXXXXX-${emp.employee.personnummer_last4}`,
|
||||
} : null,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
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)
|
||||
|
||||
// Only allow updates on draft runs
|
||||
const { data: run, error: fetchError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const allowedFields = ['payment_date', 'voucher_series', 'notes']
|
||||
const updates: Record<string, unknown> = {}
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
updates[field] = body[field]
|
||||
}
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
}
|
||||
@@ -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 { CreateSalaryRunSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const year = searchParams.get('year')
|
||||
|
||||
let query = supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (year) {
|
||||
query = query.eq('period_year', parseInt(year))
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('period_year', { ascending: false }).order('period_month', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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, CreateSalaryRunSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Check for existing run
|
||||
const { data: existing } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('period_year', body.period_year)
|
||||
.eq('period_month', body.period_month)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Det finns redan en lönekörning för denna period' }, { status: 409 })
|
||||
}
|
||||
|
||||
const { data: run, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
period_year: body.period_year,
|
||||
period_month: body.period_month,
|
||||
payment_date: body.payment_date,
|
||||
voucher_series: body.voucher_series,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'salary_run.created',
|
||||
payload: {
|
||||
salaryRunId: run.id,
|
||||
periodYear: body.period_year,
|
||||
periodMonth: body.period_month,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: run }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { lookupTaxFromApi } from '@/lib/salary/tax-tables'
|
||||
|
||||
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') || new Date().getFullYear().toString())
|
||||
const tableNumber = parseInt(searchParams.get('table') || '0')
|
||||
const column = parseInt(searchParams.get('column') || '1')
|
||||
const income = parseFloat(searchParams.get('income') || '0')
|
||||
|
||||
if (!tableNumber || tableNumber < 29 || tableNumber > 42) {
|
||||
return NextResponse.json({ error: 'Skattetabell måste vara 29-42' }, { status: 400 })
|
||||
}
|
||||
|
||||
const taxAmount = await lookupTaxFromApi(tableNumber, column, income, year)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
year,
|
||||
tableNumber,
|
||||
column,
|
||||
income,
|
||||
taxAmount,
|
||||
source: 'skatteverket_api',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user