Salary module improvements (#250)
* 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 * feat: enhance employee management with salary type, tax status, and validation improvements * feat: Implement AGI submission flow to Skatteverket - Added AGI submission route to handle the submission process. - Created AGI client for interacting with Skatteverket's API. - Introduced AGI mappers to convert salary run data into the required AGI JSON payload format. - Enhanced API client to support custom base URLs for Skatteverket API requests. - Added types for AGI submission payload and validation results. - Implemented tests for AGI mappers to ensure correct payload structure and data handling. * feat: enhance salary module with Skatteverket integration and update dashboard navigation * Update app/api/salary/runs/[id]/agi/submit/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update app/api/salary/runs/[id]/approve/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * feat: integrate write permission check and remove Skatteverket extension --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
greptile-apps[bot]
parent
24466c6e94
commit
bb0db7a588
@@ -57,10 +57,10 @@ export async function PATCH(
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Check employee exists
|
||||
// Load existing employee for merged validation
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -69,6 +69,23 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Merged validation: combine existing + updates to check full integrity
|
||||
const merged = { ...existing, ...body }
|
||||
const mergedErrors: string[] = []
|
||||
|
||||
if (merged.salary_type === 'monthly' && (!merged.monthly_salary || merged.monthly_salary <= 0)) {
|
||||
mergedErrors.push('Månadslön krävs och måste vara större än 0 för månadslöneform')
|
||||
}
|
||||
if (merged.salary_type === 'hourly' && (!merged.hourly_rate || merged.hourly_rate <= 0)) {
|
||||
mergedErrors.push('Timlön krävs och måste vara större än 0 för timlöneform')
|
||||
}
|
||||
if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) {
|
||||
mergedErrors.push('Skattetabell krävs för A-skatt anställda')
|
||||
}
|
||||
if (mergedErrors.length > 0) {
|
||||
return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updates: Record<string, unknown> = { ...body }
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => mockCreateClient(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/events', () => ({
|
||||
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
|
||||
}))
|
||||
|
||||
// Mock fetch for the internal extension API call
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
import { POST } from '../route'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
// ── Test data ────────────────────────────────────────────────
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const makeSalaryRun = (overrides = {}) => ({
|
||||
id: 'run-1',
|
||||
company_id: 'company-1',
|
||||
period_year: 2026,
|
||||
period_month: 3,
|
||||
status: 'approved',
|
||||
total_gross: 35000,
|
||||
total_tax: 8000,
|
||||
total_net: 27000,
|
||||
total_avgifter: 10997,
|
||||
total_vacation_accrual: 4200,
|
||||
total_employer_cost: 50197,
|
||||
payment_date: '2026-03-25',
|
||||
agi_generated_at: '2026-03-20T10:00:00Z',
|
||||
agi_submitted_at: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeAgiDeclaration = (overrides = {}) => ({
|
||||
id: 'agi-1',
|
||||
status: 'generated',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/salary/runs/[id]/agi/submit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when salary run not found', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: null, error: { message: 'Not found' } }, // salary_runs query
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toContain('hittades inte')
|
||||
})
|
||||
|
||||
it('returns 400 when salary run is in draft status', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makeSalaryRun({ status: 'draft' }) }, // salary_runs query
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('efter granskning')
|
||||
})
|
||||
|
||||
it('returns 400 when AGI has not been generated', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makeSalaryRun() }, // salary_runs query
|
||||
{ data: null }, // agi_declarations query (not found)
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('inte genererats')
|
||||
})
|
||||
|
||||
it('returns 409 when AGI has already been submitted', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makeSalaryRun() },
|
||||
{ data: makeAgiDeclaration({ status: 'submitted' }) },
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('redan skickats')
|
||||
})
|
||||
|
||||
it('submits AGI draft and returns success', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makeSalaryRun() }, // salary_runs query
|
||||
{ data: makeAgiDeclaration() }, // agi_declarations query
|
||||
{ data: null }, // salary_runs update (agi_submitted_at)
|
||||
])
|
||||
|
||||
// Mock the internal fetch to extension endpoint
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
inlamningId: 'inl-123',
|
||||
kontrollresultat: { kontroller: [] },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.inlamningId).toBe('inl-123')
|
||||
expect(body.data.salaryRunId).toBe('run-1')
|
||||
expect(body.data.periodYear).toBe(2026)
|
||||
expect(body.data.periodMonth).toBe(3)
|
||||
expect(body.data.message).toContain('utkast')
|
||||
|
||||
// Verify the extension endpoint was called correctly
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/extensions/ext/skatteverket/agi/draft'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ salaryRunId: 'run-1' }),
|
||||
})
|
||||
)
|
||||
|
||||
// Verify event emitted
|
||||
expect(eventBus.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agi.submitted',
|
||||
payload: expect.objectContaining({
|
||||
salaryRunId: 'run-1',
|
||||
periodYear: 2026,
|
||||
periodMonth: 3,
|
||||
companyId: 'company-1',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns error when extension draft endpoint fails', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makeSalaryRun() },
|
||||
{ data: makeAgiDeclaration() },
|
||||
])
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
error: 'Du har inte behörighet att agera för detta företag',
|
||||
code: 'BEHORIGHET_SAKNAS',
|
||||
}),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error).toContain('behörighet')
|
||||
})
|
||||
|
||||
it('accepts booked salary runs for submission', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makeSalaryRun({ status: 'booked' }) },
|
||||
{ data: makeAgiDeclaration() },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { inlamningId: 'inl-456' } }),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
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()
|
||||
|
||||
/**
|
||||
* Submit AGI to Skatteverket via the extension API.
|
||||
*
|
||||
* This route orchestrates the AGI submission flow:
|
||||
* 1. Validates the salary run is in a submittable state
|
||||
* 2. Ensures AGI has been generated (in agi_declarations table)
|
||||
* 3. Calls the Skatteverket extension to save draft + lock for signing
|
||||
* 4. Returns the signeringslänk for BankID signing
|
||||
*
|
||||
* The user then signs on Skatteverket's site. The frontend polls
|
||||
* GET /api/extensions/ext/skatteverket/agi/submitted to detect completion.
|
||||
*/
|
||||
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 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 skickas till Skatteverket efter granskning' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure AGI has been generated
|
||||
const { data: agiDeclaration } = await supabase
|
||||
.from('agi_declarations')
|
||||
.select('id, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('salary_run_id', id)
|
||||
.single()
|
||||
|
||||
if (!agiDeclaration) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AGI har inte genererats ännu. Generera AGI XML först.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (agiDeclaration.status === 'submitted' || agiDeclaration.status === 'accepted') {
|
||||
return NextResponse.json(
|
||||
{ error: 'AGI har redan skickats till Skatteverket för denna period' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// The actual submission is done via the Skatteverket extension routes.
|
||||
// This route provides the salary_run_id for the extension to load data from.
|
||||
// The frontend should call:
|
||||
// 1. POST /api/extensions/ext/skatteverket/agi/draft { salaryRunId }
|
||||
// 2. PUT /api/extensions/ext/skatteverket/agi/lock ?arbetsgivare=...&period=...
|
||||
// 3. User signs with BankID via signeringslänk
|
||||
// 4. GET /api/extensions/ext/skatteverket/agi/submitted ?arbetsgivare=...&period=...
|
||||
//
|
||||
// This endpoint kicks off step 1 and returns the info needed for step 2+.
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
try {
|
||||
// Call the extension's draft endpoint internally
|
||||
const draftResponse = await fetch(
|
||||
`${appUrl}/api/extensions/ext/skatteverket/agi/draft`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': request.headers.get('Cookie') || '',
|
||||
},
|
||||
body: JSON.stringify({ salaryRunId: id }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!draftResponse.ok) {
|
||||
const errorData = await draftResponse.json().catch(() => ({ error: 'Okänt fel' }))
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error || `Kunde inte spara AGI-utkast (${draftResponse.status})` },
|
||||
{ status: draftResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const draftData = await draftResponse.json()
|
||||
|
||||
// Update submission timestamp on salary run
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
.update({ agi_submitted_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'agi.submitted',
|
||||
payload: {
|
||||
salaryRunId: id,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...draftData.data,
|
||||
salaryRunId: id,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
message: 'AGI sparad som utkast hos Skatteverket. Lås och signera med BankID för att slutföra.',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[salary/agi/submit] Error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { eventBus } from '@/lib/events'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** review → approved (authorization recorded) */
|
||||
/** review → approved (authorization recorded, with pre-approve validation) */
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -22,7 +22,65 @@ export async function POST(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data: run, error } = await supabase
|
||||
// Verify run exists and is in review status
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'review')
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load all employees in this run for validation
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
const validationErrors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
for (const sre of runEmployees || []) {
|
||||
const emp = sre.employee as {
|
||||
first_name: string
|
||||
last_name: string
|
||||
clearing_number: string | null
|
||||
bank_account_number: string | null
|
||||
email: string | null
|
||||
} | null
|
||||
if (!emp) continue
|
||||
const name = `${emp.first_name} ${emp.last_name}`
|
||||
|
||||
// Bank details required for payment
|
||||
if (!emp.clearing_number || !emp.bank_account_number) {
|
||||
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
|
||||
}
|
||||
|
||||
// Must have been calculated (calculation_breakdown exists)
|
||||
if (!sre.calculation_breakdown) {
|
||||
validationErrors.push(`${name}: Beräkning saknas — kör beräkning först`)
|
||||
}
|
||||
|
||||
// Warning: no email means pay slip cannot be sent
|
||||
if (!emp.email) {
|
||||
warnings.push(`${name}: E-post saknas — lönebesked kan inte skickas`)
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'Valideringsfel — korrigera innan godkännande',
|
||||
details: validationErrors,
|
||||
warnings,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// All validation passed — approve
|
||||
const { data: updatedRun, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
status: 'approved',
|
||||
@@ -35,8 +93,8 @@ export async function POST(
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
|
||||
if (error || !updatedRun) {
|
||||
return NextResponse.json({ error: 'Kunde inte godkänna lönekörningen' }, { status: 500 })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
@@ -44,5 +102,5 @@ export async function POST(
|
||||
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: run })
|
||||
return NextResponse.json({ data: updatedRun, warnings })
|
||||
}
|
||||
|
||||
@@ -54,6 +54,30 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Pre-calculation validation — ensure employees have required data
|
||||
const validationErrors: string[] = []
|
||||
for (const sre of runEmployees) {
|
||||
const emp = sre.employee
|
||||
if (!emp) continue
|
||||
const name = `${emp.first_name} ${emp.last_name}`
|
||||
|
||||
if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) {
|
||||
validationErrors.push(`${name}: Månadslön saknas eller är 0`)
|
||||
}
|
||||
if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) {
|
||||
validationErrors.push(`${name}: Timlön saknas eller är 0`)
|
||||
}
|
||||
if (emp.f_skatt_status === 'a_skatt' && !emp.is_sidoinkomst && !emp.tax_table_number) {
|
||||
validationErrors.push(`${name}: Skattetabell saknas (krävs för A-skatt)`)
|
||||
}
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'Valideringsfel — korrigera anställda innan beräkning',
|
||||
details: validationErrors,
|
||||
}, { 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))]
|
||||
|
||||
Reference in New Issue
Block a user