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>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
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()
|
|
})
|
|
})
|