diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index cffba063..c1b44277 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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({ = { ...body } diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts index 439aee7e..04f53224 100644 --- a/app/api/salary/employees/route.ts +++ b/app/api/salary/employees/route.ts @@ -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) diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts index e4ff3b7c..f7476618 100644 --- a/app/api/v1/companies/[companyId]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -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( { diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 647bd7b5..d4ef0019 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 } + 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 /). diff --git a/components/settings/useSettingsNavItems.ts b/components/settings/useSettingsNavItems.ts index dcdb417a..a046131e 100644 --- a/components/settings/useSettingsNavItems.ts +++ b/components/settings/useSettingsNavItems.ts @@ -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 }, diff --git a/lib/company/context.ts b/lib/company/context.ts index 4e2b6c37..7720a374 100644 --- a/lib/company/context.ts +++ b/lib/company/context.ts @@ -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 { + 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. */ diff --git a/lib/salary/__tests__/ef-no-owner-employee.pg.test.ts b/lib/salary/__tests__/ef-no-owner-employee.pg.test.ts new file mode 100644 index 00000000..791101c4 --- /dev/null +++ b/lib/salary/__tests__/ef-no-owner-employee.pg.test.ts @@ -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 { + 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() + }) +}) diff --git a/lib/salary/employment-rules.ts b/lib/salary/employment-rules.ts new file mode 100644 index 00000000..5cd2ff11 --- /dev/null +++ b/lib/salary/employment-rules.ts @@ -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) +} diff --git a/messages/en.json b/messages/en.json index 05f8f927..ab906e3f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": "Current year: 2026 — Arbetsgivaravgifter 31.42%, prisbasbelopp 59,200 SEK" }, "settings_backup": { diff --git a/messages/sv.json b/messages/sv.json index 6ba61ec0..b4dc6352 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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": "Aktuellt år: 2026 — Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK" }, "settings_backup": { diff --git a/supabase/migrations/20260628120000_ef_no_owner_employee.sql b/supabase/migrations/20260628120000_ef_no_owner_employee.sql new file mode 100644 index 00000000..38a95c4f --- /dev/null +++ b/supabase/migrations/20260628120000_ef_no_owner_employee.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index 64d294b0..bfebf012 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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