-- Rules table (one per association) CREATE TABLE public.bill_approval_rules ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), association_id uuid NOT NULL UNIQUE REFERENCES public.associations(id) ON DELETE CASCADE, enabled boolean NOT NULL DEFAULT true, required_approvals integer NOT NULL DEFAULT 2 CHECK (required_approvals >= 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TRIGGER trg_bill_approval_rules_updated_at BEFORE UPDATE ON public.bill_approval_rules FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- Amount thresholds CREATE TABLE public.bill_approval_rule_thresholds ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), rule_id uuid NOT NULL REFERENCES public.bill_approval_rules(id) ON DELETE CASCADE, min_amount numeric(12,2) NOT NULL DEFAULT 0, max_amount numeric(12,2), required_approvals integer NOT NULL CHECK (required_approvals >= 0), created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_batr_rule ON public.bill_approval_rule_thresholds(rule_id); -- Vendor exceptions CREATE TABLE public.bill_approval_rule_vendor_exceptions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), rule_id uuid NOT NULL REFERENCES public.bill_approval_rules(id) ON DELETE CASCADE, vendor_name text NOT NULL, required_approvals integer NOT NULL CHECK (required_approvals >= 0), created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_barve_rule ON public.bill_approval_rule_vendor_exceptions(rule_id); CREATE INDEX idx_barve_vendor ON public.bill_approval_rule_vendor_exceptions(rule_id, lower(vendor_name)); -- RLS ALTER TABLE public.bill_approval_rules ENABLE ROW LEVEL SECURITY; ALTER TABLE public.bill_approval_rule_thresholds ENABLE ROW LEVEL SECURITY; ALTER TABLE public.bill_approval_rule_vendor_exceptions ENABLE ROW LEVEL SECURITY; CREATE POLICY "Staff manage approval rules" ON public.bill_approval_rules FOR ALL TO authenticated USING (public.has_role(auth.uid(),'admin'::app_role) OR public.has_role(auth.uid(),'manager'::app_role)) WITH CHECK (public.has_role(auth.uid(),'admin'::app_role) OR public.has_role(auth.uid(),'manager'::app_role)); CREATE POLICY "Board can view their approval rules" ON public.bill_approval_rules FOR SELECT TO authenticated USING (association_id IN (SELECT bm.association_id FROM public.board_members bm WHERE bm.user_id = auth.uid())); CREATE POLICY "Staff manage thresholds" ON public.bill_approval_rule_thresholds FOR ALL TO authenticated USING (public.has_role(auth.uid(),'admin'::app_role) OR public.has_role(auth.uid(),'manager'::app_role)) WITH CHECK (public.has_role(auth.uid(),'admin'::app_role) OR public.has_role(auth.uid(),'manager'::app_role)); CREATE POLICY "Board view thresholds" ON public.bill_approval_rule_thresholds FOR SELECT TO authenticated USING (rule_id IN (SELECT r.id FROM public.bill_approval_rules r WHERE r.association_id IN (SELECT bm.association_id FROM public.board_members bm WHERE bm.user_id = auth.uid()))); CREATE POLICY "Staff manage vendor exceptions" ON public.bill_approval_rule_vendor_exceptions FOR ALL TO authenticated USING (public.has_role(auth.uid(),'admin'::app_role) OR public.has_role(auth.uid(),'manager'::app_role)) WITH CHECK (public.has_role(auth.uid(),'admin'::app_role) OR public.has_role(auth.uid(),'manager'::app_role)); CREATE POLICY "Board view vendor exceptions" ON public.bill_approval_rule_vendor_exceptions FOR SELECT TO authenticated USING (rule_id IN (SELECT r.id FROM public.bill_approval_rules r WHERE r.association_id IN (SELECT bm.association_id FROM public.board_members bm WHERE bm.user_id = auth.uid()))); -- Helper: required approvals for a bill CREATE OR REPLACE FUNCTION public.get_required_approvals_for_bill(p_bill_id uuid) RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public' AS $$ DECLARE v_assoc uuid; v_amount numeric; v_vendor text; v_rule public.bill_approval_rules%ROWTYPE; v_required integer; BEGIN SELECT b.association_id, b.amount, COALESCE(b.vendor_name, '') INTO v_assoc, v_amount, v_vendor FROM public.bills b WHERE b.id = p_bill_id; IF v_assoc IS NULL THEN RETURN NULL; END IF; SELECT * INTO v_rule FROM public.bill_approval_rules WHERE association_id = v_assoc; IF v_rule.id IS NULL OR v_rule.enabled = false THEN RETURN NULL; -- no rule: legacy behavior END IF; -- Vendor exception wins SELECT required_approvals INTO v_required FROM public.bill_approval_rule_vendor_exceptions WHERE rule_id = v_rule.id AND lower(vendor_name) = lower(v_vendor) LIMIT 1; IF v_required IS NOT NULL THEN RETURN v_required; END IF; -- Threshold match (most specific: highest min_amount that fits) SELECT required_approvals INTO v_required FROM public.bill_approval_rule_thresholds WHERE rule_id = v_rule.id AND COALESCE(v_amount,0) >= min_amount AND (max_amount IS NULL OR COALESCE(v_amount,0) <= max_amount) ORDER BY min_amount DESC LIMIT 1; IF v_required IS NOT NULL THEN RETURN v_required; END IF; RETURN v_rule.required_approvals; END; $$; -- Update recompute trigger function to use rule CREATE OR REPLACE FUNCTION public.recompute_bill_status_from_approvals() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$ DECLARE v_bill_id uuid; v_total int; v_approved int; v_denied int; v_required int; v_current_status text; v_new_status text; BEGIN v_bill_id := COALESCE(NEW.bill_id, OLD.bill_id); IF v_bill_id IS NULL THEN RETURN COALESCE(NEW, OLD); END IF; SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'approved'), COUNT(*) FILTER (WHERE status = 'denied') INTO v_total, v_approved, v_denied FROM public.bill_approvals WHERE bill_id = v_bill_id; IF v_total = 0 THEN RETURN COALESCE(NEW, OLD); END IF; SELECT status INTO v_current_status FROM public.bills WHERE id = v_bill_id; v_required := public.get_required_approvals_for_bill(v_bill_id); IF v_denied > 0 THEN v_new_status := 'denied'; ELSIF v_required IS NOT NULL AND v_approved >= v_required THEN v_new_status := 'approved'; ELSIF v_required IS NULL AND v_approved = v_total THEN -- legacy: no rule configured, all recorded votes must approve v_new_status := 'approved'; ELSE v_new_status := NULL; END IF; IF v_new_status IS NOT NULL AND v_current_status IS DISTINCT FROM v_new_status AND COALESCE(v_current_status, '') NOT IN ('paid', 'cancelled') THEN UPDATE public.bills SET status = v_new_status, updated_at = now(), approved_date = CASE WHEN v_new_status = 'approved' THEN COALESCE(approved_date, CURRENT_DATE) ELSE approved_date END WHERE id = v_bill_id; END IF; RETURN COALESCE(NEW, OLD); END; $$;