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
parent 5bacda4839
commit 55ba66908b
13 changed files with 326 additions and 13 deletions
+18 -3
View File
@@ -184,7 +184,7 @@ export default async function DashboardLayout({
] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox')
.eq('company_id', companyId)
.single(),
// Shared worklist predicates (lib/worklist) — the badge must show the
@@ -210,9 +210,23 @@ export default async function DashboardLayout({
// Use company_name from settings as the display name (companies.name may be stale)
const displayName = settings?.company_name || companyRow.name
const companyWithName = { ...companyRow, name: displayName }
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Resolve entity type the same way the report engines and
// getCompanyEntityType do: company_settings is read-primary, companies is the
// canonical fallback, then default to enskild_firma. Mirroring it onto the
// active company keeps the settings rail (useSettingsNavItems, which reads
// context) and the sidebar in agreement on who is an employer. #782
const entityType =
(settings?.entity_type as EntityType) ||
(companyRow.entity_type as EntityType) ||
'enskild_firma'
const paysSalaries = settings?.pays_salaries ?? false
const companyWithName = {
...companyRow,
name: displayName,
entity_type: entityType,
pays_salaries: paysSalaries,
}
const isSandbox = settings?.is_sandbox === true
@@ -271,6 +285,7 @@ export default async function DashboardLayout({
<DashboardNav
companyName={settings?.company_name || 'Min verksamhet'}
entityType={entityType}
paysSalaries={paysSalaries}
uncategorizedTransactionCount={uncategorizedCount}
pendingOperationsCount={pendingOpsCount}
isSandbox={isSandbox}
+13 -1
View File
@@ -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 }
+11 -1
View File
@@ -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(
{
+19 -5
View File
@@ -63,6 +63,10 @@ interface ExtensionNavItem {
interface DashboardNavProps {
companyName: string
entityType: EntityType
// Whether the company has registered as an employer (company_settings.
// pays_salaries). Drives visibility of the payroll (Personal) section for
// non-aktiebolag — notably an enskild firma that hires staff. See #782.
paysSalaries?: boolean
uncategorizedTransactionCount?: number
pendingOperationsCount?: number
isSandbox?: boolean
@@ -113,7 +117,10 @@ interface NavItem {
labelKey: NavLabelKey
icon: typeof LayoutDashboard
group: GroupKey
modes?: EntityType[]
// Payroll surfaces — visible only to employers: every aktiebolag (unchanged
// behaviour) plus any company that has registered as an employer via
// company_settings.pays_salaries (e.g. an enskild firma with staff). #782
employerOnly?: boolean
hidden?: boolean
comingSoon?: boolean
devBadge?: boolean
@@ -141,8 +148,11 @@ const navItems: NavItem[] = [
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
// Personal — "Beta" badge while we validate the end-to-end salary + AGI flow.
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
// employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an
// enskild firma that hires staff gets payroll. Owner self-payroll stays
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', employerOnly: true, betaBadge: true },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', employerOnly: true, betaBadge: true },
]
// Map known extension hrefs to nav translation keys so sidebar labels translate.
@@ -172,7 +182,7 @@ function accountInitial(name: string | null, email: string | null): string {
return '?'
}
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
@@ -265,10 +275,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
return <Icon className={className} />
}
const isEmployer = entityType === 'aktiebolag' || paysSalaries
const filteredItems = navItems.filter(item => {
if (item.hidden) return false
if (hiddenNavHrefs.has(item.href)) return false
if (item.modes && !item.modes.includes(entityType)) return false
// Payroll (employerOnly) is hidden until the company is an employer — an
// aktiebolag, or any entity that has flagged pays_salaries. #782
if (item.employerOnly && !isEmployer) return false
// Hide the Assistent (/chat) tab until the agent is built — mirrors the
// floating AgentTrigger and avoids a nav entry that only bounces to the
// home checklist (chat/layout redirects unverified users to /).
+4 -1
View File
@@ -50,7 +50,10 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
{ id: 'company', href: '/settings/company', label: t('company'), group: 'company', show: hasCompany },
{ id: 'bookkeeping', href: '/settings/bookkeeping', label: t('bookkeeping'), group: 'accounting', show: hasCompany },
{ id: 'tax', href: '/settings/tax', label: t('tax'), group: 'accounting', show: hasCompany },
{ id: 'salary', href: '/settings/salary', label: t('salary'), group: 'accounting', show: hasCompany && company?.entity_type === 'aktiebolag' },
// Lön settings follow the sidebar: every aktiebolag, plus any company that
// has registered as an employer (pays_salaries) — e.g. an enskild firma
// with staff. #782
{ id: 'salary', href: '/settings/salary', label: t('salary'), group: 'accounting', show: hasCompany && (company?.entity_type === 'aktiebolag' || !!company?.pays_salaries) },
{ id: 'invoicing', href: '/settings/invoicing', label: t('invoicing'), group: 'sales', show: hasCompany },
{ id: 'templates', href: '/settings/templates', label: t('templates'), group: 'sales', show: hasCompany },
{ id: 'banking', href: '/settings/banking', label: t('banking'), group: 'tools', show: hasCompany && !isSandbox && hasBankingExtension },
+30
View File
@@ -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()
})
})
+39
View File
@@ -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)
}
+1 -1
View File
@@ -1162,7 +1162,7 @@
"vacation_supplement_cba": "0.80% (common collective agreement level)",
"vacation_supplement_help": "Applied with sammalöneregeln. Can be overridden per employee.",
"info_heading": "Information",
"info_payroll_scope": "The payroll module handles salaries for aktiebolag. Enskild firma owners use eget uttag instead.",
"info_payroll_scope": "The payroll module handles salaries for your employees tax deductions, employer contributions, vacation pay liability and the employer declaration (AGI). If you run a sole proprietorship (enskild firma), you as the owner take money out via owner's drawings (account 2013), not salary the module is for your employees.",
"info_current_year": "<strong>Current year:</strong> 2026 — Arbetsgivaravgifter 31.42%, prisbasbelopp 59,200 SEK"
},
"settings_backup": {
+1 -1
View File
@@ -1162,7 +1162,7 @@
"vacation_supplement_cba": "0,80% (vanligt kollektivavtalsbelopp)",
"vacation_supplement_help": "Tillämpas vid sammalöneregeln. Kan ändras per anställd.",
"info_heading": "Information",
"info_payroll_scope": "Lönemodulen hanterar löner för aktiebolag. Enskild firma-ägare använder eget uttag istället.",
"info_payroll_scope": "Lönemodulen hanterar löner till dina anställda skatteavdrag, arbetsgivaravgifter, semesterlöneskuld och arbetsgivardeklaration (AGI). Driver du enskild firma tar du som ägare ut pengar via eget uttag (konto 2013), inte lön modulen är till för dina anställda.",
"info_current_year": "<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK"
},
"settings_backup": {
@@ -0,0 +1,53 @@
-- Enforce that an enskild firma cannot put its owner or board 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_member (styrelse) is an aktiebolag concept and likewise doesn't exist
-- for an EF. Ordinary employees (employment_type 'employee') remain fully
-- allowed for an EF that hires staff and book identically to an aktiebolag.
--
-- This trigger is the all-paths backstop (UI route, /api/v1, MCP, direct SQL)
-- for the application-layer guard in lib/salary/employment-rules.ts. The two
-- must agree on the forbidden set: company_owner, board_member. See #782.
CREATE OR REPLACE FUNCTION public.enforce_ef_no_owner_employee()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_entity_type text;
BEGIN
-- Only owner/board employment types are constrained; ordinary employees are
-- allowed for every entity type, so leave those untouched.
IF NEW.employment_type NOT IN ('company_owner', 'board_member') THEN
RETURN NEW;
END IF;
-- Resolve the company's effective entity type: company_settings is the
-- read-primary source the app uses, with companies as the canonical
-- fallback (mirrors lib/company/context.getCompanyEntityType()).
v_entity_type := COALESCE(
(SELECT cs.entity_type FROM public.company_settings cs WHERE cs.company_id = NEW.company_id),
(SELECT c.entity_type FROM public.companies c WHERE c.id = NEW.company_id)
);
IF v_entity_type = 'enskild_firma' THEN
RAISE EXCEPTION 'En enskild firma kan inte ha sin ägare eller styrelse som anställd (employment_type=%). Ägaren tar ut pengar via eget uttag (konto 2013), inte lön.', NEW.employment_type
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
-- Fires on INSERT and on UPDATEs that touch employment_type only — so an
-- unrelated edit to a grandfathered row (created before this guard existed)
-- is never blocked, but setting/keeping an owner type on an EF is.
DROP TRIGGER IF EXISTS trg_enforce_ef_no_owner_employee ON public.employees;
CREATE TRIGGER trg_enforce_ef_no_owner_employee
BEFORE INSERT OR UPDATE OF employment_type ON public.employees
FOR EACH ROW
EXECUTE FUNCTION public.enforce_ef_no_owner_employee();
NOTIFY pgrst, 'reload schema';
+5
View File
@@ -35,6 +35,11 @@ export interface Company {
archived_at: string | null
created_at: string
updated_at: string
// Denormalised from company_settings onto the active company in the
// dashboard layout so context consumers (e.g. the settings rail) can tell
// whether the company is a registered employer without an extra fetch.
// Optional because it isn't a column on `companies`. #782
pays_salaries?: boolean
}
// Company membership