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:
Jakob Wennberg
2026-06-26 15:30:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5bacda4839
commit 55ba66908b
13 changed files with 326 additions and 13 deletions
@@ -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(
{