Files
accounted/supabase/migrations/20260628120000_ef_no_owner_employee.sql
Jakob Wennberg 55ba66908b 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>
2026-06-26 15:30:03 +02:00

54 lines
2.4 KiB
PL/PgSQL

-- 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';