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:
Mattsson
2026-04-15 11:17:39 +02:00
committed by GitHub
parent b387a77bfd
commit 04dbb31d7e
78 changed files with 10529 additions and 14 deletions
+28
View File
@@ -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 })
}
}
+29
View File
@@ -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 })
}
}
+136
View File
@@ -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 } })
}
+121
View File
@@ -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 })
}
+148
View File
@@ -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 })
}
}
+228
View File
@@ -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)
}
+48
View File
@@ -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 })
}
+125
View File
@@ -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 })
}
}
+211
View File
@@ -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 })
}
+165
View File
@@ -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 } })
}
+119
View File
@@ -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 } })
}
+86
View File
@@ -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 })
}
+41
View File
@@ -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,
},
})
}
+191
View File
@@ -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,
},
})
}
+38
View File
@@ -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 })
}
+63
View File
@@ -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,
})
}
+103
View File
@@ -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 })
}
+97
View File
@@ -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 })
}
+32
View File
@@ -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',
},
})
}