feat(salary): let an enskild firma employ staff while blocking owner/board payroll (#797)
An enskild firma that hires staff should get the payroll module, but its owner or board can never be on payroll (owner compensation is egna uttag / BAS 2013, not lön). - Migration 20260628120000 adds the enforce_ef_no_owner_employee trigger (BEFORE INSERT OR UPDATE OF employment_type) as the all-paths backstop. - lib/salary/employment-rules.ts is the app-layer mirror (forbidden set kept byte-identical to the trigger); getCompanyEntityType() resolves the same company_settings -> companies precedence. - The two UI salary routes and the v1 POST guard before insert/update for a clean 400 with guidance. - Payroll nav + Lön settings now show for any employer (aktiebolag OR company_settings.pays_salaries), wired through the dashboard layout. Fixes #782. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5bacda4839
commit
55ba66908b
@@ -3,9 +3,10 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -86,6 +87,17 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only when the caller is changing employment_type: block setting an EF's
|
||||
// owner/board on payroll (mirrors the enforce_ef_no_owner_employee trigger,
|
||||
// which fires on UPDATE OF employment_type — so unrelated edits to any
|
||||
// grandfathered row aren't blocked). #782
|
||||
if (body.employment_type !== undefined) {
|
||||
const entityType = await getCompanyEntityType(supabase, companyId)
|
||||
if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) {
|
||||
return NextResponse.json({ error: EF_OWNER_EMPLOYMENT_ERROR }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updates: Record<string, unknown> = { ...body }
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -63,6 +64,15 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: pnrValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// An enskild firma owner cannot be put on payroll (they take egna uttag, not
|
||||
// lön). Block owner/board employment types for EF before inserting. The DB
|
||||
// trigger enforce_ef_no_owner_employee is the all-paths backstop; this gives
|
||||
// a clean 400 with guidance. #782
|
||||
const entityType = await getCompanyEntityType(supabase, companyId)
|
||||
if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) {
|
||||
return NextResponse.json({ error: EF_OWNER_EMPLOYMENT_ERROR }, { status: 400 })
|
||||
}
|
||||
|
||||
// Encrypt personnummer
|
||||
const encryptedPnr = encryptPersonnummer(body.personnummer)
|
||||
const last4 = extractLast4(body.personnummer)
|
||||
|
||||
@@ -29,6 +29,8 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateEmployeeSchema } from '@/lib/api/schemas'
|
||||
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
|
||||
import { getCompanyEntityType } from '@/lib/company/context'
|
||||
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
|
||||
|
||||
const EmploymentType = z.enum(['employee', 'company_owner', 'board_member'])
|
||||
const SalaryType = z.enum(['monthly', 'hourly'])
|
||||
@@ -349,6 +351,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// An enskild firma owner cannot be put on payroll (owner takes egna uttag,
|
||||
// not lön). Reject owner/board employment types for EF — validated before
|
||||
// dry-run so a dry run surfaces the error too. The DB trigger is the
|
||||
// all-paths backstop. #782
|
||||
const entityType = await getCompanyEntityType(ctx.supabase, ctx.companyId!)
|
||||
if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user