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:
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 /).
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user