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
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
const COMPANY_COOKIE = 'gnubok-company-id'
|
||||
|
||||
@@ -70,6 +71,35 @@ export async function getActiveCompanyId(
|
||||
return firstCompany?.company_id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a company's effective entity type.
|
||||
*
|
||||
* `company_settings.entity_type` is the read-primary source (what the user
|
||||
* edits in settings and what the sidebar reads), with the canonical
|
||||
* `companies.entity_type` as the fallback — mirroring app/api/settings and the
|
||||
* report engines. Returns null only if the company can't be found.
|
||||
*/
|
||||
export async function getCompanyEntityType(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<EntityType | null> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (settings?.entity_type) return settings.entity_type as EntityType
|
||||
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
return (company?.entity_type as EntityType | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all companies the user is a member of, with their roles.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertAuthUser, insertCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Locks in the enforce_ef_no_owner_employee trigger (migration
|
||||
* 20260628120000): an enskild firma may employ staff, but never put its owner
|
||||
* or board on payroll (owner compensation is egna uttag, not lön). Ordinary
|
||||
* employees stay allowed for every entity type. See #782.
|
||||
*
|
||||
* Inserts go through getPool() (superuser, bypasses RLS) — this exercises the
|
||||
* trigger, not tenant isolation.
|
||||
*/
|
||||
|
||||
async function insertEmployee(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
employmentType: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.employees
|
||||
(id, user_id, company_id, first_name, last_name, personnummer,
|
||||
personnummer_last4, employment_start, employment_type, monthly_salary,
|
||||
tax_table_number)
|
||||
VALUES ($1, $2, $3, 'Test', 'Person', '199001011234', '1234',
|
||||
'2026-01-01', $4, 30000, 32)`,
|
||||
[id, params.userId, params.companyId, params.employmentType],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('enforce_ef_no_owner_employee.pg — owner/board payroll blocked for EF', () => {
|
||||
it('rejects company_owner for an enskild firma', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'enskild_firma' })
|
||||
await expect(
|
||||
insertEmployee({ userId, companyId, employmentType: 'company_owner' }),
|
||||
).rejects.toThrow(/kan inte ha sin ägare/i)
|
||||
})
|
||||
|
||||
it('rejects board_member for an enskild firma', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'enskild_firma' })
|
||||
await expect(
|
||||
insertEmployee({ userId, companyId, employmentType: 'board_member' }),
|
||||
).rejects.toThrow(/kan inte ha sin ägare/i)
|
||||
})
|
||||
|
||||
it('allows ordinary employees for an enskild firma', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'enskild_firma' })
|
||||
await expect(
|
||||
insertEmployee({ userId, companyId, employmentType: 'employee' }),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('allows company_owner for an aktiebolag', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'aktiebolag' })
|
||||
await expect(
|
||||
insertEmployee({ userId, companyId, employmentType: 'company_owner' }),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('uses company_settings.entity_type over companies (settings is read-primary)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
// companies says aktiebolag, but the user re-classified to EF in settings.
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'aktiebolag' })
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, entity_type)
|
||||
VALUES ($1, $2, 'enskild_firma')`,
|
||||
[userId, companyId],
|
||||
)
|
||||
await expect(
|
||||
insertEmployee({ userId, companyId, employmentType: 'company_owner' }),
|
||||
).rejects.toThrow(/kan inte ha sin ägare/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enforce_ef_no_owner_employee.pg — UPDATE semantics', () => {
|
||||
it('rejects changing an EF employee to company_owner', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'enskild_firma' })
|
||||
const empId = await insertEmployee({ userId, companyId, employmentType: 'employee' })
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.employees SET employment_type = 'company_owner' WHERE id = $1`,
|
||||
[empId],
|
||||
),
|
||||
).rejects.toThrow(/kan inte ha sin ägare/i)
|
||||
})
|
||||
|
||||
it('allows unrelated edits to an EF employee (trigger fires only on employment_type)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'enskild_firma' })
|
||||
const empId = await insertEmployee({ userId, companyId, employmentType: 'employee' })
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.employees SET monthly_salary = 42000 WHERE id = $1`,
|
||||
[empId],
|
||||
),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('allows changing an aktiebolag employee to company_owner', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId, entityType: 'aktiebolag' })
|
||||
const empId = await insertEmployee({ userId, companyId, employmentType: 'employee' })
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.employees SET employment_type = 'company_owner' WHERE id = $1`,
|
||||
[empId],
|
||||
),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* Employment policy: which employment types an entity type may put on payroll.
|
||||
*
|
||||
* An enskild firma is not a separate legal person from its owner, so the owner
|
||||
* cannot be their own employee and cannot be paid lön — owner compensation is
|
||||
* *egna uttag* (BAS 2013), booked against equity, never a salary cost. Board
|
||||
* members (styrelse) are an aktiebolag concept and likewise don't exist for an
|
||||
* EF. Ordinary employees (employment_type 'employee') are fully allowed for an
|
||||
* EF that hires staff and book identically to an aktiebolag (7xxx + 27xx/29xx
|
||||
* + 1930, never the 20xx equity accounts).
|
||||
*
|
||||
* This module is the application-layer mirror of the
|
||||
* `enforce_ef_no_owner_employee` database trigger (the load-bearing,
|
||||
* all-paths enforcement). Keep the two in sync — the forbidden set below must
|
||||
* match the trigger's. See issue #782.
|
||||
*/
|
||||
export const EF_FORBIDDEN_EMPLOYMENT_TYPES = ['company_owner', 'board_member'] as const
|
||||
|
||||
/** User-facing Swedish error when an EF tries to put its owner/board on payroll. */
|
||||
export const EF_OWNER_EMPLOYMENT_ERROR =
|
||||
'En enskild firma kan inte ha sin ägare eller styrelse som anställd. ' +
|
||||
'Som ägare tar du ut pengar via eget uttag (konto 2013), inte lön. ' +
|
||||
'Lägg bara upp dina anställda (anställningstyp "employee").'
|
||||
|
||||
/**
|
||||
* Whether `employmentType` is permitted for a company of `entityType`.
|
||||
* Permissive for everything except owner/board employment on an enskild firma,
|
||||
* and for unknown/empty inputs (which the schema's enum check handles).
|
||||
*/
|
||||
export function isEmploymentTypeAllowedForEntity(
|
||||
entityType: EntityType | null | undefined,
|
||||
employmentType: string | null | undefined,
|
||||
): boolean {
|
||||
if (entityType !== 'enskild_firma') return true
|
||||
if (!employmentType) return true
|
||||
return !(EF_FORBIDDEN_EMPLOYMENT_TYPES as readonly string[]).includes(employmentType)
|
||||
}
|
||||
Reference in New Issue
Block a user