-- ========================================================================== -- Agencia PSI — Migracao: Sistema de créditos WhatsApp (Marco B) -- ========================================================================== -- Criado por: Leonardo Nohama -- Data: 2026-04-21 · Sao Carlos/SP — Brasil -- -- Modelo: -- - whatsapp_credits_balance → saldo atual por tenant (snapshot) -- - whatsapp_credits_transactions → extrato (purchase, usage, topup, adj) -- - whatsapp_credit_packages → pacotes oferecidos (SaaS-managed) -- - whatsapp_credit_purchases → ordens de compra via Asaas -- -- Helpers (RPC): -- - add_whatsapp_credits(tenant, amount, kind, ...) → novo saldo -- - deduct_whatsapp_credits(tenant, amount, message_id) → boolean -- -- Creditos so sao deduzidos quando o tenant usa provider='twilio' -- (Evolution e free). -- ========================================================================== -- --------------------------------------------------------------------------- -- Saldo por tenant -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.whatsapp_credits_balance ( tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE, balance INT NOT NULL DEFAULT 0 CHECK (balance >= 0), lifetime_purchased INT NOT NULL DEFAULT 0, lifetime_used INT NOT NULL DEFAULT 0, low_balance_threshold INT NOT NULL DEFAULT 20 CHECK (low_balance_threshold >= 0), low_balance_alerted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_wa_credits_balance_updated_at ON public.whatsapp_credits_balance; CREATE TRIGGER trg_wa_credits_balance_updated_at BEFORE UPDATE ON public.whatsapp_credits_balance FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); COMMENT ON TABLE public.whatsapp_credits_balance IS 'Saldo atual de creditos WhatsApp por tenant. 1 credito = 1 mensagem Twilio.'; -- --------------------------------------------------------------------------- -- Extrato (transações) -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.whatsapp_credits_transactions ( id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('purchase', 'usage', 'topup_manual', 'refund', 'adjustment')), amount INT NOT NULL, -- positivo = credito, negativo = debito balance_after INT NOT NULL, -- Referencias opcionais conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL, purchase_id UUID, admin_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, note TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_tenant_created ON public.whatsapp_credits_transactions (tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_kind ON public.whatsapp_credits_transactions (tenant_id, kind, created_at DESC); COMMENT ON TABLE public.whatsapp_credits_transactions IS 'Extrato de creditos WhatsApp. Append-only — nao editar/deletar.'; -- --------------------------------------------------------------------------- -- Pacotes (global, gerenciado pelo SaaS admin) -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.whatsapp_credit_packages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 100), description TEXT, credits INT NOT NULL CHECK (credits > 0), price_brl NUMERIC(10,2) NOT NULL CHECK (price_brl > 0), is_active BOOLEAN NOT NULL DEFAULT true, is_featured BOOLEAN NOT NULL DEFAULT false, position INT NOT NULL DEFAULT 100, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_wa_credit_packages_updated_at ON public.whatsapp_credit_packages; CREATE TRIGGER trg_wa_credit_packages_updated_at BEFORE UPDATE ON public.whatsapp_credit_packages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); CREATE INDEX IF NOT EXISTS idx_wa_credit_packages_active ON public.whatsapp_credit_packages (is_active, position, price_brl) WHERE is_active = true; COMMENT ON TABLE public.whatsapp_credit_packages IS 'Pacotes de creditos disponiveis pra compra. Gerenciado pelo SaaS admin.'; -- Seed: pacotes padrao INSERT INTO public.whatsapp_credit_packages (name, description, credits, price_brl, is_featured, position) VALUES ('Iniciante', 'Ideal pra conhecer a plataforma', 100, 49.90, false, 10), ('Profissional', 'Mais vendido pra clínicas pequenas', 500, 199.90, true, 20), ('Clínica', 'Pra clínicas com alto volume', 1500, 499.90, false, 30), ('Enterprise', 'Pacote grande com desconto', 5000, 1499.90, false, 40) ON CONFLICT DO NOTHING; -- --------------------------------------------------------------------------- -- Ordens de compra (Asaas integration) -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.whatsapp_credit_purchases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, package_id UUID REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL, -- Snapshot do pacote no momento da compra (caso mude de preço/creditos depois) package_name TEXT NOT NULL, credits INT NOT NULL CHECK (credits > 0), amount_brl NUMERIC(10,2) NOT NULL CHECK (amount_brl > 0), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'refunded', 'cancelled')), -- Asaas integration asaas_customer_id TEXT, asaas_payment_id TEXT, asaas_payment_link TEXT, asaas_pix_qrcode TEXT, -- base64 da imagem asaas_pix_copy_paste TEXT, -- codigo PIX copia-cola paid_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, failed_at TIMESTAMPTZ, created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_wa_credit_purchases_updated_at ON public.whatsapp_credit_purchases; CREATE TRIGGER trg_wa_credit_purchases_updated_at BEFORE UPDATE ON public.whatsapp_credit_purchases FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_tenant ON public.whatsapp_credit_purchases (tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_status ON public.whatsapp_credit_purchases (status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_asaas_payment ON public.whatsapp_credit_purchases (asaas_payment_id) WHERE asaas_payment_id IS NOT NULL; -- FK pra transactions.purchase_id (circular, então define depois) ALTER TABLE public.whatsapp_credits_transactions DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_purchase_id_fkey; ALTER TABLE public.whatsapp_credits_transactions ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL; COMMENT ON TABLE public.whatsapp_credit_purchases IS 'Ordens de compra de creditos via Asaas. Webhook atualiza status.'; -- --------------------------------------------------------------------------- -- RPC: add_whatsapp_credits (SECURITY DEFINER — atualiza saldo + registra tx) -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.add_whatsapp_credits( p_tenant_id UUID, p_amount INT, p_kind TEXT, p_purchase_id UUID DEFAULT NULL, p_admin_id UUID DEFAULT NULL, p_note TEXT DEFAULT NULL ) RETURNS INT LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_new_balance INT; BEGIN IF p_amount <= 0 THEN RAISE EXCEPTION 'amount must be positive'; END IF; IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN RAISE EXCEPTION 'invalid kind for credit: %', p_kind; END IF; INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased) VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END) ON CONFLICT (tenant_id) DO UPDATE SET balance = whatsapp_credits_balance.balance + EXCLUDED.balance, lifetime_purchased = whatsapp_credits_balance.lifetime_purchased + CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END, low_balance_alerted_at = NULL -- reset alerta quando recebe creditos RETURNING balance INTO v_new_balance; INSERT INTO public.whatsapp_credits_transactions (tenant_id, kind, amount, balance_after, purchase_id, admin_id, note) VALUES (p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_note); RETURN v_new_balance; END; $$; REVOKE ALL ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) TO service_role; -- --------------------------------------------------------------------------- -- RPC: deduct_whatsapp_credits (atomico, falha se saldo insuficiente) -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.deduct_whatsapp_credits( p_tenant_id UUID, p_amount INT, p_conversation_message_id BIGINT DEFAULT NULL, p_note TEXT DEFAULT NULL ) RETURNS INT LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_new_balance INT; v_row RECORD; BEGIN IF p_amount <= 0 THEN RAISE EXCEPTION 'amount must be positive'; END IF; -- Lock a linha e valida saldo SELECT balance, low_balance_threshold INTO v_row FROM public.whatsapp_credits_balance WHERE tenant_id = p_tenant_id FOR UPDATE; IF NOT FOUND THEN RAISE EXCEPTION 'insufficient_credits'; END IF; IF v_row.balance < p_amount THEN RAISE EXCEPTION 'insufficient_credits'; END IF; UPDATE public.whatsapp_credits_balance SET balance = balance - p_amount, lifetime_used = lifetime_used + p_amount WHERE tenant_id = p_tenant_id RETURNING balance INTO v_new_balance; INSERT INTO public.whatsapp_credits_transactions (tenant_id, kind, amount, balance_after, conversation_message_id, note) VALUES (p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note); RETURN v_new_balance; END; $$; REVOKE ALL ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) TO service_role; -- --------------------------------------------------------------------------- -- RLS -- --------------------------------------------------------------------------- ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY; ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY; ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY; -- Balance: members do tenant leem, saas_admin tudo DROP POLICY IF EXISTS "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance; CREATE POLICY "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance FOR SELECT TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active' ) ); -- Settings update: members do tenant podem alterar low_balance_threshold DROP POLICY IF EXISTS "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance; CREATE POLICY "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance FOR UPDATE TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active' ) ) WITH CHECK ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active' ) ); -- Transactions: read-only pra tenant members, write via RPC DROP POLICY IF EXISTS "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions; CREATE POLICY "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions FOR SELECT TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_transactions.tenant_id AND tm.status = 'active' ) ); -- Packages: todos leem os ativos; saas_admin gerencia DROP POLICY IF EXISTS "wa_packages: select active" ON public.whatsapp_credit_packages; CREATE POLICY "wa_packages: select active" ON public.whatsapp_credit_packages FOR SELECT TO authenticated USING (is_active = true OR public.is_saas_admin()); DROP POLICY IF EXISTS "wa_packages: manage saas admin" ON public.whatsapp_credit_packages; CREATE POLICY "wa_packages: manage saas admin" ON public.whatsapp_credit_packages FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin()); -- Purchases: members do tenant leem as proprias DROP POLICY IF EXISTS "wa_purchases: select tenant" ON public.whatsapp_credit_purchases; CREATE POLICY "wa_purchases: select tenant" ON public.whatsapp_credit_purchases FOR SELECT TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credit_purchases.tenant_id AND tm.status = 'active' ) ); -- ========================================================================== -- FIM DA MIGRACAO -- ==========================================================================