-- ========================================================================== -- Agencia PSI — Migracao: Ajuste manual SaaS admin (WhatsApp credits) -- ========================================================================== -- Criado por: Leonardo Nohama -- Data: 2026-04-23 · Sao Carlos/SP — Brasil -- -- Contexto: -- - add_whatsapp_credits (ja existia) so aceita amount > 0, kind fixo. -- - deduct_whatsapp_credits (ja existia) e exclusiva de uso (kind='usage'). -- - Faltava fluxo de ajuste manual negativo pelo saas admin com safeguards. -- -- Regras de negocio: -- 1. Admin pode ajustar +/- ate |amount| = 1000 por operacao (anti dedo-gordo). -- Pra valores maiores, admin repete a operacao (cada uma fica auditada). -- 2. Ao REMOVER (amount < 0), so pode mexer no "pool cortesia": -- saldo derivado de topup_manual / adjustment / refund. -- Creditos originados de 'purchase' (compras Asaas/PIX) sao intocaveis — -- estorno de compra real tem que ir pelo fluxo financeiro do Asaas. -- 3. Regra de consumo (FIFO cortesia primeiro): usage sempre subtrai do -- pool cortesia antes do pool compra. Formula: -- removable = max(0, sum_topup_like - usage_total) -- protected = balance - removable -- Exemplo: topup 100 + compra 200, usado 50 -> removable=50, protegido=200. -- 4. Exige is_saas_admin() — protege contra chamada de authenticated comum. -- ========================================================================== -- --------------------------------------------------------------------------- -- Helper: breakdown pra UI mostrar removivel vs protegido -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id UUID) RETURNS TABLE ( balance INT, removable INT, protected_amount INT, topup_net INT, usage_total INT ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_balance INT := 0; v_topup_net INT := 0; v_usage_total INT := 0; v_removable INT := 0; BEGIN IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'permission_denied'; END IF; SELECT COALESCE(b.balance, 0) INTO v_balance FROM public.whatsapp_credits_balance b WHERE b.tenant_id = p_tenant_id; v_balance := COALESCE(v_balance, 0); SELECT COALESCE(SUM(amount), 0) INTO v_topup_net FROM public.whatsapp_credits_transactions WHERE tenant_id = p_tenant_id AND kind IN ('topup_manual', 'adjustment', 'refund'); SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total FROM public.whatsapp_credits_transactions WHERE tenant_id = p_tenant_id AND kind = 'usage'; v_removable := GREATEST(0, v_topup_net - v_usage_total); v_removable := LEAST(v_removable, v_balance); RETURN QUERY SELECT v_balance, v_removable, GREATEST(0, v_balance - v_removable), v_topup_net, v_usage_total; END; $$; REVOKE ALL ON FUNCTION public.get_whatsapp_removable_balance(UUID) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.get_whatsapp_removable_balance(UUID) TO authenticated, service_role; COMMENT ON FUNCTION public.get_whatsapp_removable_balance(UUID) IS 'Breakdown do saldo WhatsApp: removivel (pool cortesia restante) vs protegido (compras). Apenas saas_admin.'; -- --------------------------------------------------------------------------- -- admin_adjust_whatsapp_credits: ajuste manual SaaS admin (+/-) -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.admin_adjust_whatsapp_credits( p_tenant_id UUID, p_amount INT, -- com sinal: positivo=adicionar, negativo=remover p_admin_id UUID, p_note TEXT DEFAULT NULL ) RETURNS INT LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_new_balance INT; v_current_balance INT; v_topup_net INT; v_usage_total INT; v_removable INT; v_clean_note TEXT; BEGIN IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'permission_denied'; END IF; IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_required'; END IF; IF p_amount IS NULL OR p_amount = 0 THEN RAISE EXCEPTION 'amount_required'; END IF; IF ABS(p_amount) > 1000 THEN RAISE EXCEPTION 'amount_exceeds_limit_1000'; END IF; IF p_admin_id IS NULL THEN RAISE EXCEPTION 'admin_id_required'; END IF; v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), ''); IF v_clean_note IS NOT NULL THEN v_clean_note := LEFT(v_clean_note, 500); END IF; IF p_amount > 0 THEN -- ADICIONAR INSERT INTO public.whatsapp_credits_balance (tenant_id, balance) VALUES (p_tenant_id, p_amount) ON CONFLICT (tenant_id) DO UPDATE SET balance = whatsapp_credits_balance.balance + EXCLUDED.balance, low_balance_alerted_at = NULL RETURNING balance INTO v_new_balance; ELSE -- REMOVER (amount < 0) SELECT balance INTO v_current_balance FROM public.whatsapp_credits_balance WHERE tenant_id = p_tenant_id FOR UPDATE; IF NOT FOUND THEN RAISE EXCEPTION 'tenant_has_no_balance'; END IF; SELECT COALESCE(SUM(amount), 0) INTO v_topup_net FROM public.whatsapp_credits_transactions WHERE tenant_id = p_tenant_id AND kind IN ('topup_manual', 'adjustment', 'refund'); SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total FROM public.whatsapp_credits_transactions WHERE tenant_id = p_tenant_id AND kind = 'usage'; v_removable := GREATEST(0, v_topup_net - v_usage_total); v_removable := LEAST(v_removable, v_current_balance); IF ABS(p_amount) > v_removable THEN RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable; END IF; UPDATE public.whatsapp_credits_balance SET balance = balance + p_amount -- p_amount ja e negativo WHERE tenant_id = p_tenant_id RETURNING balance INTO v_new_balance; END IF; INSERT INTO public.whatsapp_credits_transactions (tenant_id, kind, amount, balance_after, admin_id, note) VALUES (p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note); RETURN v_new_balance; END; $$; REVOKE ALL ON FUNCTION public.admin_adjust_whatsapp_credits(UUID, INT, UUID, TEXT) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_adjust_whatsapp_credits(UUID, INT, UUID, TEXT) TO authenticated, service_role; COMMENT ON FUNCTION public.admin_adjust_whatsapp_credits(UUID, INT, UUID, TEXT) IS 'Ajuste manual SaaS admin (+/-). |amount|<=1000 por operacao. Remocao so afeta pool cortesia — purchases sao protegidas. Requer is_saas_admin().';