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
+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 },