diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index b274098a..19e5f235 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -12,7 +12,6 @@ import { ArrowLeft, Save, Trash2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { formatCurrency } from '@/lib/utils' import type { Employee } from '@/types' const EMPLOYMENT_LABELS: Record = { @@ -21,6 +20,17 @@ const EMPLOYMENT_LABELS: Record = { board_member: 'Styrelseledamot', } +const F_SKATT_LABELS: Record = { + a_skatt: 'A-skatt', + f_skatt: 'F-skatt', + fa_skatt: 'FA-skatt', + not_verified: 'Ej verifierad', +} + +function RequiredMark() { + return * +} + export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const router = useRouter() @@ -30,6 +40,12 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [employmentType, setEmploymentType] = useState('employee') + const [salaryType, setSalaryType] = useState('monthly') + const [fSkattStatus, setFSkattStatus] = useState('a_skatt') + const [isSidoinkomst, setIsSidoinkomst] = useState(false) + const [vacationRule, setVacationRule] = useState('procentregeln') + + const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst useEffect(() => { async function load() { @@ -38,6 +54,10 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s const { data } = await res.json() setEmployee(data) setEmploymentType(data.employment_type) + setSalaryType(data.salary_type || 'monthly') + setFSkattStatus(data.f_skatt_status || 'a_skatt') + setIsSidoinkomst(data.is_sidoinkomst || false) + setVacationRule(data.vacation_rule || 'procentregeln') } setLoading(false) } @@ -49,19 +69,33 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s setSaving(true) const form = new FormData(e.currentTarget) - const body = { + const body: Record = { first_name: form.get('first_name') as string, last_name: form.get('last_name') as string, employment_type: employmentType, employment_degree: parseFloat(form.get('employment_degree') as string) || 100, - monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined, - hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined, + salary_type: salaryType, + f_skatt_status: fSkattStatus, + is_sidoinkomst: isSidoinkomst, tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined, tax_column: parseInt(form.get('tax_column') as string) || 1, + tax_municipality: form.get('tax_municipality') as string || undefined, email: form.get('email') as string || undefined, phone: form.get('phone') as string || undefined, + address_line1: form.get('address_line1') as string || undefined, + postal_code: form.get('postal_code') as string || undefined, + city: form.get('city') as string || undefined, clearing_number: form.get('clearing_number') as string || undefined, bank_account_number: form.get('bank_account_number') as string || undefined, + vacation_rule: vacationRule, + vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25, + } + + // Include salary field matching the current salary_type + if (salaryType === 'monthly') { + body.monthly_salary = parseFloat(form.get('monthly_salary') as string) || undefined + } else { + body.hourly_rate = parseFloat(form.get('hourly_rate') as string) || undefined } const res = await fetch(`/api/salary/employees/${id}`, { @@ -134,22 +168,66 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
+ {/* Personal info */} - Uppgifter + Personuppgifter
- +
- +
-
+
+
+ + +

Krävs för att skicka lönebesked

+
+
+ + +
+
+ + + + {/* Address */} + + + Adress + + +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + {/* Employment */} + + + Anställning + + +
+
+
+
+
+ + {/* Salary */} + + + Lön + + +
- - + + +
+ {salaryType === 'monthly' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+
+ + {/* Tax */} + + + Skatt + + +
+
+ + + {employee.f_skatt_verified_at && ( +

+ Verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')} +

+ )} +
+
+
- - + + +

Baseras på folkbokföringskommun

- - + + +

1 = standard under 66 år

- - + +
+
+
+ + {/* Vacation */} + + + Semester + +
- - + +
- - + + +

Lagstadgat minimum: 25 dagar

+
+
+ + {/* Bank */} + + + Bankkonto + +
@@ -206,6 +412,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
+

Krävs innan lönekörning kan godkännas

diff --git a/app/(dashboard)/salary/employees/new/page.tsx b/app/(dashboard)/salary/employees/new/page.tsx index cdfb3fc9..18b71ef6 100644 --- a/app/(dashboard)/salary/employees/new/page.tsx +++ b/app/(dashboard)/salary/employees/new/page.tsx @@ -12,12 +12,20 @@ import { ArrowLeft, Save } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' +function RequiredMark() { + return * +} + export default function NewEmployeePage() { const router = useRouter() const { toast } = useToast() const [saving, setSaving] = useState(false) const [employmentType, setEmploymentType] = useState('employee') const [salaryType, setSalaryType] = useState('monthly') + const [fSkattStatus, setFSkattStatus] = useState('a_skatt') + const [isSidoinkomst, setIsSidoinkomst] = useState(false) + + const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -32,13 +40,18 @@ export default function NewEmployeePage() { employment_start: form.get('employment_start') as string, employment_degree: parseFloat(form.get('employment_degree') as string) || 100, salary_type: salaryType, - monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined, - hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined, + monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined, + hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined, + f_skatt_status: fSkattStatus, + is_sidoinkomst: isSidoinkomst, tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined, tax_column: parseInt(form.get('tax_column') as string) || 1, tax_municipality: form.get('tax_municipality') as string || undefined, email: form.get('email') as string || undefined, phone: form.get('phone') as string || undefined, + address_line1: form.get('address_line1') as string || undefined, + postal_code: form.get('postal_code') as string || undefined, + city: form.get('city') as string || undefined, clearing_number: form.get('clearing_number') as string || undefined, bank_account_number: form.get('bank_account_number') as string || undefined, } @@ -82,23 +95,24 @@ export default function NewEmployeePage() {
- +
- +
- +

Krypteras vid lagring

+

Krävs för att skicka lönebesked

@@ -108,6 +122,29 @@ export default function NewEmployeePage() { + {/* Address */} + + + Adress + + +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ {/* Employment */} @@ -129,7 +166,7 @@ export default function NewEmployeePage() {
- +
@@ -146,9 +183,9 @@ export default function NewEmployeePage() { Lön -
+
- +
-
- - -
-
- - -
+ {salaryType === 'monthly' ? ( +
+ + +
+ ) : ( +
+ + +
+ )}
@@ -177,10 +217,46 @@ export default function NewEmployeePage() { Skatt +
+
+ + +
+
+ +
+
- - + +

Baseras på folkbokföringskommun

@@ -189,8 +265,10 @@ export default function NewEmployeePage() {

1 = standard under 66 år

- - + +
@@ -212,6 +290,7 @@ export default function NewEmployeePage() {
+

Krävs innan lönekörning kan godkännas

diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index 7c1247a3..930c3c7e 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -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 = { ...body } diff --git a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts new file mode 100644 index 00000000..4673da3a --- /dev/null +++ b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts @@ -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 }>(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) + }) +}) diff --git a/app/api/salary/runs/[id]/agi/submit/route.ts b/app/api/salary/runs/[id]/agi/submit/route.ts new file mode 100644 index 00000000..79eb301a --- /dev/null +++ b/app/api/salary/runs/[id]/agi/submit/route.ts @@ -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 } + ) + } +} diff --git a/app/api/salary/runs/[id]/approve/route.ts b/app/api/salary/runs/[id]/approve/route.ts index 9c960618..bcfc6371 100644 --- a/app/api/salary/runs/[id]/approve/route.ts +++ b/app/api/salary/runs/[id]/approve/route.ts @@ -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 }) } diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index 1c4d71fc..9a30c274 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -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))] diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 16636225..0f77b514 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -79,6 +79,9 @@ const navItems: NavItem[] = [ { href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' }, { href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' }, { href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' }, + // Personal + { href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'] }, + { href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'] }, { href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' }, { href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' }, ] @@ -87,6 +90,7 @@ const groupLabels: Record = { main: 'Huvudmeny', försäljning: 'Försäljning', inköp: 'Inköp', + personal: 'Personal', redovisning: 'Redovisning', övrigt: 'Övrigt', } @@ -130,6 +134,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un if (href === '/') { return pathname === '/' } + // For parent routes that have a sibling sub-route in the nav (e.g. /salary vs /salary/employees), + // only match the parent for exact or non-overlapping sub-paths + if (href === '/salary') { + return pathname === '/salary' || pathname.startsWith('/salary/runs') + } return pathname.startsWith(href) } @@ -158,7 +167,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un const sidebarGroups = [ { key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' }, { key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' }, - { key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-6' }, + { key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-4' }, + { key: 'personal', items: filteredItems.filter(i => i.group === 'personal'), spacing: 'mb-6' }, ] as const const mobileNavItems = [ @@ -228,8 +238,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
- {/* AR / AP / Accounting groups */} - {sidebarGroups.map(({ key, items, spacing }) => ( + {/* AR / AP / Personal / Accounting groups */} + {sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items, spacing }) => (

{groupLabels[key]} @@ -553,8 +563,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un })}

- {/* AR / AP / Accounting groups (mobile) */} - {sidebarGroups.map(({ key, items }) => ( + {/* AR / AP / Personal / Accounting groups (mobile) */} + {sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
{groupLabels[key]} diff --git a/extensions/general/skatteverket/__tests__/agi-mappers.test.ts b/extensions/general/skatteverket/__tests__/agi-mappers.test.ts new file mode 100644 index 00000000..4ed6e9b0 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/agi-mappers.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { buildAGIPayload } from '../lib/agi-mappers' +import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator' + +// Mock personnummer decryption +vi.mock('@/lib/salary/personnummer', () => ({ + decryptPersonnummer: vi.fn((encrypted: string) => { + // Simulate decryption: in tests, we use plaintext personnummer + if (encrypted === 'INVALID') throw new Error('Decryption failed') + return encrypted + }), +})) + +function makeEmployee(overrides: Partial = {}): AGIEmployeeData { + return { + personnummer: '199001011234', + specificationNumber: 1, + grossSalary: 35000, + taxWithheld: 8000, + avgifterBasis: 35000, + ...overrides, + } +} + +function makeTotals(overrides: Partial = {}): AGITotals { + return { + totalTax: 8000, + totalAvgifterBasis: 35000, + avgifterByCategory: { + standard: { basis: 35000, amount: 10997 }, + }, + ...overrides, + } +} + +describe('buildAGIPayload', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('builds payload with correct structure', () => { + const result = buildAGIPayload([makeEmployee()], makeTotals()) + + expect(result).toMatchObject({ + rattelse: false, + huvuduppgift: { + avdragenSkatt: 8000, + summaArbetsgivaravgifterUnderlag: 35000, + avgifterUnderlagStandard: 35000, + }, + individuppgifter: [ + { + personnummer: '199001011234', + specifikationsnummer: 1, + kontantBruttoloen: 35000, + avdragenSkatt: 8000, + underlagArbetsgivaravgifter: 35000, + }, + ], + }) + }) + + it('sets rattelse flag for corrections', () => { + const result = buildAGIPayload([makeEmployee()], makeTotals(), true) + expect(result.rattelse).toBe(true) + }) + + it('omits zero-value fields from individuppgift', () => { + const emp = makeEmployee({ + benefitCar: 0, + benefitMeals: undefined, + sickDays: 0, + }) + const result = buildAGIPayload([emp], makeTotals()) + const ind = result.individuppgifter[0] + + expect(ind.formanBil).toBeUndefined() + expect(ind.formanKost).toBeUndefined() + expect(ind.sjukfranvaroDagar).toBeUndefined() + }) + + it('includes benefit values when present', () => { + const emp = makeEmployee({ + benefitCar: 4500, + benefitHousing: 3000, + benefitMeals: 1800, + benefitOther: 500, + }) + const result = buildAGIPayload([emp], makeTotals()) + const ind = result.individuppgifter[0] + + expect(ind.formanBil).toBe(4500) + expect(ind.formanBostad).toBe(3000) + expect(ind.formanKost).toBe(1800) + expect(ind.formanOvrigt).toBe(500) + }) + + it('includes absence fields when present', () => { + const emp = makeEmployee({ + sickDays: 3, + vabDays: 2, + parentalDays: 5, + }) + const result = buildAGIPayload([emp], makeTotals()) + const ind = result.individuppgifter[0] + + expect(ind.sjukfranvaroDagar).toBe(3) + expect(ind.vabDagar).toBe(2) + expect(ind.foraldraledigDagar).toBe(5) + }) + + it('includes F-skatt payment field', () => { + const emp = makeEmployee({ fSkattPayment: 50000 }) + const result = buildAGIPayload([emp], makeTotals()) + + expect(result.individuppgifter[0].ersattningFSkatt).toBe(50000) + }) + + it('rounds all amounts to whole kronor', () => { + const emp = makeEmployee({ + grossSalary: 35000.75, + taxWithheld: 8000.49, + avgifterBasis: 35000.5, + }) + const result = buildAGIPayload([emp], makeTotals()) + const ind = result.individuppgifter[0] + + expect(ind.kontantBruttoloen).toBe(35001) + expect(ind.avdragenSkatt).toBe(8000) + expect(ind.underlagArbetsgivaravgifter).toBe(35001) + }) + + it('handles multiple avgifter categories', () => { + const totals = makeTotals({ + avgifterByCategory: { + standard: { basis: 70000, amount: 21994 }, + reduced65plus: { basis: 30000, amount: 3063 }, + youth: { basis: 25000, amount: 5203 }, + }, + }) + const result = buildAGIPayload([makeEmployee()], totals) + const hu = result.huvuduppgift + + expect(hu.avgifterUnderlagStandard).toBe(70000) + expect(hu.avgifterUnderlagAlderspension).toBe(30000) + expect(hu.avgifterUnderlagUngdom).toBe(25000) + }) + + it('handles multiple employees', () => { + const employees = [ + makeEmployee({ specificationNumber: 1, grossSalary: 35000 }), + makeEmployee({ specificationNumber: 2, personnummer: '199512152345', grossSalary: 28000 }), + ] + const result = buildAGIPayload(employees, makeTotals({ totalTax: 15000, totalAvgifterBasis: 63000 })) + + expect(result.individuppgifter).toHaveLength(2) + expect(result.individuppgifter[0].specifikationsnummer).toBe(1) + expect(result.individuppgifter[1].specifikationsnummer).toBe(2) + expect(result.individuppgifter[1].personnummer).toBe('199512152345') + }) + + it('throws if personnummer cannot be decrypted', () => { + const emp = makeEmployee({ personnummer: 'INVALID' }) + + expect(() => buildAGIPayload([emp], makeTotals())).toThrow( + /Kunde inte dekryptera personnummer.*FK570=1/ + ) + }) + + it('omits huvuduppgift fields when zero', () => { + const totals: AGITotals = { + totalTax: 0, + totalAvgifterBasis: 0, + avgifterByCategory: {}, + } + const result = buildAGIPayload([makeEmployee({ grossSalary: 0, taxWithheld: 0, avgifterBasis: 0 })], totals) + const hu = result.huvuduppgift + + expect(hu.avdragenSkatt).toBeUndefined() + expect(hu.summaArbetsgivaravgifterUnderlag).toBeUndefined() + expect(hu.avgifterUnderlagStandard).toBeUndefined() + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index b97d5302..5f58c28f 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -6,6 +6,9 @@ import { storeTokens, getTokens, deleteTokens } from './lib/token-store' import { skvRequest, SkatteverketAuthError } from './lib/api-client' import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers' import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' +import { agiSaveDraft, agiValidate, agiGetSubmission, agiDeleteDraft, agiLockPeriod, agiUnlockPeriod, agiGetSubmitted } from './lib/agi-client' +import { buildAGIPayload } from './lib/agi-mappers' +import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator' import type { VatPeriodType } from '@/types' /** @@ -546,6 +549,364 @@ export const skatteverketExtension: Extension = { } }, }, + // ══════════════════════════════════════════════════════════════ + // AGI (Arbetsgivardeklaration) routes + // ══════════════════════════════════════════════════════════════ + + // ── AGI: Validate (dry run) ──────────────────────────────────── + { + method: 'POST', + path: '/agi/validate', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { arbetsgivare, period, payload } = await parseAGIRequest(request, ctx) + + console.log('[skatteverket] AGI validating:', { arbetsgivare, period }) + + const result = await agiValidate(ctx.supabase, ctx.companyId, arbetsgivare, period, payload) + + if (!result.ok) { + console.error('[skatteverket] AGI validate error:', result.status, result.error) + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + return NextResponse.json({ data: result.data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Save draft ──────────────────────────────────────────── + { + method: 'POST', + path: '/agi/draft', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { arbetsgivare, period, payload, salaryRunId } = await parseAGIRequest(request, ctx) + + console.log('[skatteverket] AGI saving draft:', { arbetsgivare, period }) + + const result = await agiSaveDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, payload) + + if (!result.ok) { + console.error('[skatteverket] AGI draft error:', result.status, result.error) + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + // Track submission status and inlämningsId + const inlamningId = result.data?.inlamningId + await ctx.settings.set( + `agi_submission_${period}`, + JSON.stringify({ + status: 'draft_saved', + arbetsgivare, + period, + inlamningId, + salaryRunId, + kontrollresultat: result.data?.kontrollresultat, + updatedAt: new Date().toISOString(), + }) + ) + + // Update agi_declarations table with submission status + if (salaryRunId) { + await ctx.supabase + .from('agi_declarations') + .update({ status: 'exported' }) + .eq('salary_run_id', salaryRunId) + .eq('company_id', ctx.companyId) + } + + return NextResponse.json({ data: result.data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Get submission ──────────────────────────────────────── + { + method: 'GET', + path: '/agi/submission', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const url = new URL(request.url) + const arbetsgivare = url.searchParams.get('arbetsgivare') + const period = url.searchParams.get('period') + const inlamningId = url.searchParams.get('inlamningId') + + if (!arbetsgivare || !period || !inlamningId) { + return NextResponse.json( + { error: 'Saknar parametrar: arbetsgivare, period, inlamningId' }, + { status: 400 } + ) + } + + const result = await agiGetSubmission(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId) + + if (!result.ok) { + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + return NextResponse.json({ data: result.data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Delete draft ────────────────────────────────────────── + { + method: 'DELETE', + path: '/agi/draft', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const url = new URL(request.url) + const arbetsgivare = url.searchParams.get('arbetsgivare') + const period = url.searchParams.get('period') + const inlamningId = url.searchParams.get('inlamningId') + + if (!arbetsgivare || !period || !inlamningId) { + return NextResponse.json( + { error: 'Saknar parametrar: arbetsgivare, period, inlamningId' }, + { status: 400 } + ) + } + + const result = await agiDeleteDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId) + + if (!result.ok) { + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + await ctx.settings.set(`agi_submission_${period}`, null) + return NextResponse.json({ success: true }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Lock period for signing ─────────────────────────────── + { + method: 'PUT', + path: '/agi/lock', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const url = new URL(request.url) + const arbetsgivare = url.searchParams.get('arbetsgivare') + const period = url.searchParams.get('period') + + if (!arbetsgivare || !period) { + return NextResponse.json( + { error: 'Saknar parametrar: arbetsgivare, period' }, + { status: 400 } + ) + } + + const result = await agiLockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period) + + if (!result.ok) { + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + await ctx.settings.set( + `agi_submission_${period}`, + JSON.stringify({ + status: 'draft_locked', + arbetsgivare, + period, + signeringslank: result.data?.signeringslank, + updatedAt: new Date().toISOString(), + }) + ) + + return NextResponse.json({ data: result.data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Unlock period ───────────────────────────────────────── + { + method: 'DELETE', + path: '/agi/lock', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const url = new URL(request.url) + const arbetsgivare = url.searchParams.get('arbetsgivare') + const period = url.searchParams.get('period') + + if (!arbetsgivare || !period) { + return NextResponse.json( + { error: 'Saknar parametrar: arbetsgivare, period' }, + { status: 400 } + ) + } + + const result = await agiUnlockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period) + + if (!result.ok) { + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + await ctx.settings.set( + `agi_submission_${period}`, + JSON.stringify({ + status: 'draft_saved', + arbetsgivare, + period, + updatedAt: new Date().toISOString(), + }) + ) + + return NextResponse.json({ success: true }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Fetch submitted (after BankID signing) ──────────────── + { + method: 'GET', + path: '/agi/submitted', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const url = new URL(request.url) + const arbetsgivare = url.searchParams.get('arbetsgivare') + const period = url.searchParams.get('period') + + if (!arbetsgivare || !period) { + return NextResponse.json( + { error: 'Saknar parametrar: arbetsgivare, period' }, + { status: 400 } + ) + } + + const result = await agiGetSubmitted(ctx.supabase, ctx.companyId, arbetsgivare, period) + + if (!result.ok) { + return NextResponse.json( + { error: `Skatteverket svarade med ${result.status}: ${result.error}` }, + { status: result.status } + ) + } + + // If we got a kvittensnummer, the AGI has been signed and submitted + if (result.data?.kvittensnummer) { + await ctx.settings.set( + `agi_submission_${period}`, + JSON.stringify({ + status: 'signed', + arbetsgivare, + period, + kvittensnummer: result.data.kvittensnummer, + tidpunkt: result.data.tidpunkt, + signerare: result.data.signerare, + updatedAt: new Date().toISOString(), + }) + ) + + // Update agi_declarations with submission receipt + const periodYear = parseInt(period.slice(0, 4)) + const periodMonth = parseInt(period.slice(4, 6)) + await ctx.supabase + .from('agi_declarations') + .update({ + status: 'submitted', + kvittensnummer: result.data.kvittensnummer, + submitted_at: result.data.tidpunkt || new Date().toISOString(), + submitted_by: ctx.userId, + }) + .eq('company_id', ctx.companyId) + .eq('period_year', periodYear) + .eq('period_month', periodMonth) + } + + return NextResponse.json({ data: result.data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── AGI: Get submission status (local tracking) ──────────────── + { + method: 'GET', + path: '/agi/status', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + const url = new URL(request.url) + const period = url.searchParams.get('period') + + if (!period) { + return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 }) + } + + const statusJson = await ctx.settings.get(`agi_submission_${period}`) + if (!statusJson) { + return NextResponse.json({ data: null }) + } + + try { + return NextResponse.json({ data: JSON.parse(statusJson) }) + } catch { + return NextResponse.json({ data: null }) + } + }, + }, ], } @@ -624,6 +985,128 @@ function parseQueryParams( return { redovisare, redovisningsperiod } } +/** + * Parse and validate AGI submission request body. + * Loads salary run data and builds the Skatteverket AGI JSON payload. + */ +async function parseAGIRequest( + request: Request, + ctx: ExtensionContext +): Promise<{ + arbetsgivare: string + period: string + payload: ReturnType + salaryRunId: string +}> { + const body = await request.json() + const { salaryRunId } = body as { salaryRunId: string } + + if (!salaryRunId) { + throw new Error('Saknar obligatoriskt fält: salaryRunId') + } + + // Get company settings for arbetsgivare formatting + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('org_number, entity_type') + .eq('company_id', ctx.companyId) + .single() + + if (!settings?.org_number) { + throw new Error('Organisationsnummer saknas i företagsinställningar') + } + + // Load salary run + const { data: run, error: runError } = await ctx.supabase + .from('salary_runs') + .select('*') + .eq('id', salaryRunId) + .eq('company_id', ctx.companyId) + .single() + + if (runError || !run) { + throw new Error('Lönekörning hittades inte') + } + + if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) { + throw new Error('AGI kan bara skickas efter granskning') + } + + // Load employees with their data + const { data: runEmployees } = await ctx.supabase + .from('salary_run_employees') + .select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)') + .eq('salary_run_id', salaryRunId) + + if (!runEmployees || runEmployees.length === 0) { + throw new Error('Inga anställda i lönekörningen') + } + + // Build employee data + 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> + + const sumByType = (types: string[]) => + lineItems + .filter(li => types.includes(li.item_type as string)) + .reduce((sum, li) => sum + ((li.amount as number) || 0), 0) + + 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: sumByType(['benefit_car']) || undefined, + benefitHousing: sumByType(['benefit_housing']) || undefined, + benefitMeals: sumByType(['benefit_meals']) || undefined, + benefitOther: sumByType(['benefit_wellness', 'benefit_other']) || 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 + const avgifterByCategory: AGITotals['avgifterByCategory'] = {} + for (const sre of runEmployees) { + const dbCategory = sre.avgifter_category as string | null + 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)[category] = cat + } + + const totals: AGITotals = { + totalTax: run.total_tax, + totalAvgifterBasis: runEmployees.reduce((s: number, e: { avgifter_basis: number }) => s + e.avgifter_basis, 0), + avgifterByCategory, + } + + // Check if this is a correction + const { data: existingAgi } = await ctx.supabase + .from('agi_declarations') + .select('id, status') + .eq('company_id', ctx.companyId) + .eq('period_year', run.period_year) + .eq('period_month', run.period_month) + .in('status', ['submitted', 'accepted']) + .single() + + const isCorrection = !!existingAgi + + const arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type) + const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month) + const payload = buildAGIPayload(employeeData, totals, isCorrection) + + return { arbetsgivare, period, payload, salaryRunId } +} + /** * Convert Skatteverket errors to appropriate HTTP responses. */ diff --git a/extensions/general/skatteverket/lib/agi-client.ts b/extensions/general/skatteverket/lib/agi-client.ts new file mode 100644 index 00000000..0ebfe06d --- /dev/null +++ b/extensions/general/skatteverket/lib/agi-client.ts @@ -0,0 +1,229 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { skvRequest } from './api-client' +import type { SkatteverketAGIInlamning, SkatteverketAGIKontrollresultat } from '../types' + +/** + * Skatteverket AGI (Arbetsgivardeklaration) API client. + * + * Follows the same pattern as the Momsdeklaration API: + * kontrollera → utkast → lås → (BankID signering) → inlämnat + * + * Base URL: https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1 + * + * Endpoint pattern: + * /arbetsgivare/{arbetsgivarregistrerad}/redovisningsperioder/{redovisningsperiod}/... + */ + +const DEFAULT_AGI_API_BASE_URL = + 'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1' + +function getAgiApiBaseUrl(): string { + return process.env.SKATTEVERKET_AGI_API_BASE_URL || DEFAULT_AGI_API_BASE_URL +} + +function basePath(arbetsgivare: string, period: string): string { + return `/arbetsgivare/${arbetsgivare}/redovisningsperioder/${period}` +} + +/** + * Validate AGI data (dry run) without saving. + * Returns validation errors/warnings. + */ +export async function agiValidate( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string, + payload: SkatteverketAGIInlamning +): Promise<{ ok: boolean; status: number; data?: SkatteverketAGIKontrollresultat; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'POST', + `${basePath(arbetsgivare, period)}/kontrollera`, + payload, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (!response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + const data = await response.json() + return { ok: true, status: response.status, data } +} + +/** + * Save AGI as draft to Skatteverket's "Eget utrymme". + * Returns kontrollresultat and inlämningsId. + */ +export async function agiSaveDraft( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string, + payload: SkatteverketAGIInlamning +): Promise<{ ok: boolean; status: number; data?: { inlamningId?: string; kontrollresultat?: SkatteverketAGIKontrollresultat }; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'POST', + `${basePath(arbetsgivare, period)}/inlamningar`, + payload, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (!response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + const data = await response.json() + return { ok: true, status: response.status, data } +} + +/** + * Get a specific AGI submission. + */ +export async function agiGetSubmission( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string, + inlamningId: string +): Promise<{ ok: boolean; status: number; data?: unknown; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'GET', + `${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`, + undefined, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (response.status === 404) { + return { ok: true, status: 404, data: null } + } + + if (!response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + const data = await response.json() + return { ok: true, status: response.status, data } +} + +/** + * Delete a draft AGI submission. + */ +export async function agiDeleteDraft( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string, + inlamningId: string +): Promise<{ ok: boolean; status: number; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'DELETE', + `${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`, + undefined, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (response.status !== 204 && !response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + return { ok: true, status: response.status } +} + +/** + * Lock the reporting period for signing. + * Returns a signeringslänk for BankID signing on Skatteverket's site. + */ +export async function agiLockPeriod( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string +): Promise<{ ok: boolean; status: number; data?: { signeringslank?: string }; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'PUT', + `${basePath(arbetsgivare, period)}/las`, + undefined, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (!response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + const data = await response.json() + return { ok: true, status: response.status, data } +} + +/** + * Unlock a locked reporting period (cancel signing). + */ +export async function agiUnlockPeriod( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string +): Promise<{ ok: boolean; status: number; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'DELETE', + `${basePath(arbetsgivare, period)}/las`, + undefined, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (response.status !== 204 && !response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + return { ok: true, status: response.status } +} + +/** + * Fetch submitted AGI (after signing). + * Returns kvittensnummer and submission timestamp. + */ +export async function agiGetSubmitted( + supabase: SupabaseClient, + userId: string, + arbetsgivare: string, + period: string +): Promise<{ ok: boolean; status: number; data?: { kvittensnummer?: string; tidpunkt?: string; signerare?: string } | null; error?: string }> { + const response = await skvRequest( + supabase, + userId, + 'GET', + `${basePath(arbetsgivare, period)}/inlamnat`, + undefined, + { baseUrl: getAgiApiBaseUrl() } + ) + + if (response.status === 404) { + return { ok: true, status: 404, data: null } + } + + if (!response.ok) { + const text = await response.text() + return { ok: false, status: response.status, error: text } + } + + const data = await response.json() + return { ok: true, status: response.status, data } +} diff --git a/extensions/general/skatteverket/lib/agi-mappers.ts b/extensions/general/skatteverket/lib/agi-mappers.ts new file mode 100644 index 00000000..9a3df1f6 --- /dev/null +++ b/extensions/general/skatteverket/lib/agi-mappers.ts @@ -0,0 +1,93 @@ +import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator' +import type { SkatteverketAGIInlamning, SkatteverketHuvuduppgift, SkatteverketIndividuppgift } from '../types' +import { decryptPersonnummer } from '@/lib/salary/personnummer' + +// Re-export shared formatting utilities +export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' + +/** + * Convert gnubok salary run data to Skatteverket AGI JSON payload. + * + * JSON property names are derived from Skatteverket's XML element names, + * following the same camelCase convention as the Momsdeklaration API. + * The exact names should be verified against the RAML spec on Utvecklarportalen. + * + * CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee. + * Using a different number creates a new record instead of a correction. + */ +export function buildAGIPayload( + employees: AGIEmployeeData[], + totals: AGITotals, + isCorrection: boolean = false +): SkatteverketAGIInlamning { + const huvuduppgift = buildHuvuduppgift(totals) + const individuppgifter = employees.map(emp => buildIndividuppgift(emp)) + + return { + rattelse: isCorrection, + huvuduppgift, + individuppgifter, + } +} + +function buildHuvuduppgift(totals: AGITotals): SkatteverketHuvuduppgift { + const result: SkatteverketHuvuduppgift = {} + + if (totals.totalTax > 0) { + result.avdragenSkatt = Math.round(totals.totalTax) + } + if (totals.totalAvgifterBasis > 0) { + result.summaArbetsgivaravgifterUnderlag = Math.round(totals.totalAvgifterBasis) + } + + // Avgifter by category (rutor 060-062) + if (totals.avgifterByCategory.standard) { + result.avgifterUnderlagStandard = Math.round(totals.avgifterByCategory.standard.basis) + } + if (totals.avgifterByCategory.reduced65plus) { + result.avgifterUnderlagAlderspension = Math.round(totals.avgifterByCategory.reduced65plus.basis) + } + if (totals.avgifterByCategory.youth) { + result.avgifterUnderlagUngdom = Math.round(totals.avgifterByCategory.youth.basis) + } + + return result +} + +function buildIndividuppgift(emp: AGIEmployeeData): SkatteverketIndividuppgift { + // Decrypt personnummer — must be plaintext for Skatteverket + let personnummer: string + try { + personnummer = decryptPersonnummer(emp.personnummer) + } catch { + throw new Error( + `Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` + + 'AGI kan inte skickas utan giltigt personnummer.' + ) + } + + const result: SkatteverketIndividuppgift = { + personnummer, + specifikationsnummer: emp.specificationNumber, + } + + // Only include non-zero values (Skatteverket treats absent fields as 0) + if (emp.grossSalary > 0) result.kontantBruttoloen = Math.round(emp.grossSalary) + if (emp.taxWithheld > 0) result.avdragenSkatt = Math.round(emp.taxWithheld) + if (emp.avgifterBasis > 0) result.underlagArbetsgivaravgifter = Math.round(emp.avgifterBasis) + if (emp.fSkattPayment && emp.fSkattPayment > 0) result.ersattningFSkatt = Math.round(emp.fSkattPayment) + + // Benefits (rutor 012-019) + if (emp.benefitCar && emp.benefitCar > 0) result.formanBil = Math.round(emp.benefitCar) + if (emp.benefitFuel && emp.benefitFuel > 0) result.formanDrivmedel = Math.round(emp.benefitFuel) + if (emp.benefitHousing && emp.benefitHousing > 0) result.formanBostad = Math.round(emp.benefitHousing) + if (emp.benefitMeals && emp.benefitMeals > 0) result.formanKost = Math.round(emp.benefitMeals) + if (emp.benefitOther && emp.benefitOther > 0) result.formanOvrigt = Math.round(emp.benefitOther) + + // Absence fields (from 2025) + if (emp.sickDays && emp.sickDays > 0) result.sjukfranvaroDagar = Math.round(emp.sickDays) + if (emp.vabDays && emp.vabDays > 0) result.vabDagar = Math.round(emp.vabDays) + if (emp.parentalDays && emp.parentalDays > 0) result.foraldraledigDagar = Math.round(emp.parentalDays) + + return result +} diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index cbb26e4e..f4153e64 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -111,13 +111,14 @@ export async function skvRequest( userId: string, method: string, path: string, - body?: unknown + body?: unknown, + options?: { baseUrl?: string } ): Promise { const accessToken = await getValidToken(supabase, userId) await enforceRateLimit() - const url = `${getApiBaseUrl()}${path}` + const url = `${options?.baseUrl || getApiBaseUrl()}${path}` const headers: Record = { 'Authorization': `Bearer ${accessToken}`, 'Client_Id': getApiGwClientId(), diff --git a/extensions/general/skatteverket/types.ts b/extensions/general/skatteverket/types.ts index 26c718ea..acef776c 100644 --- a/extensions/general/skatteverket/types.ts +++ b/extensions/general/skatteverket/types.ts @@ -84,6 +84,72 @@ export type DeclarationStatus = | 'signed' | 'decided' +// ── AGI (Arbetsgivardeklaration) types ────────────────────────── + +/** + * AGI submission payload — sent to Skatteverket inlämning API. + * + * JSON property names follow the same camelCase convention as the + * Momsdeklaration API. Derived from Skatteverket's XML element names + * and FK field codes. Verify against the RAML spec on Utvecklarportalen. + */ +export interface SkatteverketAGIInlamning { + rattelse: boolean + huvuduppgift: SkatteverketHuvuduppgift + individuppgifter: SkatteverketIndividuppgift[] +} + +/** Employer-level totals (Huvuduppgift) */ +export interface SkatteverketHuvuduppgift { + /** Ruta 001: Total avdragen skatt */ + avdragenSkatt?: number + /** Ruta 020: Total underlag arbetsgivaravgifter */ + summaArbetsgivaravgifterUnderlag?: number + /** Ruta 060: Avgifter — standard rate (31.42%) */ + avgifterUnderlagStandard?: number + /** Ruta 061: Avgifter — ålderspension only (10.21%, 67+ from 2026) */ + avgifterUnderlagAlderspension?: number + /** Ruta 062: Avgifter — youth rate (20.81%, ages 19-23, Apr 2026–Sep 2027) */ + avgifterUnderlagUngdom?: number +} + +/** Per-employee data (Individuppgift) */ +export interface SkatteverketIndividuppgift { + /** FK215: Personnummer/samordningsnummer (12 digits, plaintext) */ + personnummer: string + /** FK570: Specifikationsnummer — MUST stay consistent per employee */ + specifikationsnummer: number + /** Ruta 011: Kontant bruttolön */ + kontantBruttoloen?: number + /** Ruta 001: Avdragen skatt */ + avdragenSkatt?: number + /** Ruta 012: Förmån bil */ + formanBil?: number + /** Ruta 013: Förmån drivmedel */ + formanDrivmedel?: number + /** Ruta 014: Förmån bostad */ + formanBostad?: number + /** Ruta 015: Förmån kost */ + formanKost?: number + /** Ruta 019: Förmån övrigt */ + formanOvrigt?: number + /** Ruta 020: Underlag arbetsgivaravgifter */ + underlagArbetsgivaravgifter?: number + /** Ruta 131: Ersättning till F-skatt holder */ + ersattningFSkatt?: number + /** FK821: Sjukfrånvaro dagar */ + sjukfranvaroDagar?: number + /** FK822: VAB dagar */ + vabDagar?: number + /** FK823: Föräldraledighet dagar */ + foraldraledigDagar?: number +} + +/** AGI validation result from Skatteverket /kontrollera */ +export interface SkatteverketAGIKontrollresultat { + kontroller?: SkatteverketKontroll[] +} + export interface SkatteverketSubmission { id: string user_id: string diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 85a052b9..6f97d00b 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -636,7 +636,8 @@ export const SalaryLineItemTypeSchema = z.enum([ 'correction', 'other', ]) -export const CreateEmployeeSchema = z.object({ +// Base employee object (no refinements — safe for .partial()) +const EmployeeSchemaBase = z.object({ first_name: z.string().min(1).max(200), last_name: z.string().min(1).max(200), personnummer: z.string().regex(/^\d{12}$/, 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)'), @@ -667,7 +668,75 @@ export const CreateEmployeeSchema = z.object({ vaxa_stod_end: isoDate.optional(), }) -export const UpdateEmployeeSchema = CreateEmployeeSchema.partial() +export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) => { + // Salary amount required based on salary_type + if (data.salary_type === 'monthly' && (data.monthly_salary === undefined || data.monthly_salary === null || data.monthly_salary <= 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Månadslön krävs och måste vara större än 0 för månadslöneform', + path: ['monthly_salary'], + }) + } + if (data.salary_type === 'hourly' && (data.hourly_rate === undefined || data.hourly_rate === null || data.hourly_rate <= 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Timlön krävs och måste vara större än 0 för timlöneform', + path: ['hourly_rate'], + }) + } + + // Tax table required for A-skatt employees (not sidoinkomst) + if (data.f_skatt_status === 'a_skatt' && !data.is_sidoinkomst && !data.tax_table_number) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Skattetabell krävs för A-skatt anställda (baseras på folkbokföringskommun)', + path: ['tax_table_number'], + }) + } + + // Tax municipality recommended when tax table is set + if (data.tax_table_number && !data.tax_municipality) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Folkbokföringskommun bör anges för att dokumentera skattetabellens underlag', + path: ['tax_municipality'], + }) + } +}) + +export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => { + // Only validate salary when salary_type is being changed in this update + if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Månadslön måste vara större än 0 för månadslöneform', + path: ['monthly_salary'], + }) + } + if (data.salary_type === 'hourly' && data.hourly_rate !== undefined && data.hourly_rate <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Timlön måste vara större än 0 för timlöneform', + path: ['hourly_rate'], + }) + } + + // If setting salary_type, require the corresponding salary field + if (data.salary_type === 'monthly' && !('monthly_salary' in data)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Månadslön måste anges vid byte till månadslöneform', + path: ['monthly_salary'], + }) + } + if (data.salary_type === 'hourly' && !('hourly_rate' in data)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Timlön måste anges vid byte till timlöneform', + path: ['hourly_rate'], + }) + } +}) export const CreateSalaryRunSchema = z.object({ period_year: z.number().int().min(2020).max(2100), diff --git a/lib/events/types.ts b/lib/events/types.ts index bfbd5c2e..5bc700dc 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -79,6 +79,7 @@ export type CoreEvent = | { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } } | { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } } | { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } } + | { type: 'agi.submitted'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } } // Company & account lifecycle | { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } } | { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }