From f1c97ee9065c21d1dee6ba864674e6916ea88f3e Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 23 Apr 2026 22:31:15 -0300 Subject: [PATCH] =?UTF-8?q?Dashboard=20SaaS=20ganha=20se=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20receita=20de=20cr=C3=A9ditos=20WhatsApp=20(Asaas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fecha o gap de analytics que faltava: MRR/ARR de assinatura já existia, mas não havia visão de receita dos créditos WhatsApp comprados via Asaas. Banco (migration 20260423000011) — 4 RPCs saas_admin only: - saas_wa_credits_revenue_stats(from, to): total arrecadado, count de compras, tenants únicos, créditos vendidos, ticket médio. - saas_wa_credits_top_packages(from, to): ranking top 10 pacotes por revenue, consolida nome atual se pacote foi renomeado. - saas_wa_credits_usage_summary(): snapshot atual de lifetime_purchased vs lifetime_used vs current_balance + taxa de consumo. - saas_wa_credits_revenue_evolution(from, to, bucket_days): série temporal pra sparkline. Todas com check is_saas_admin() no início + SECURITY DEFINER. Frontend: - useSaasCreditsAnalytics composable orquestra as 4 RPCs em paralelo com seleção de período (30d/90d/6m/12m) que ajusta bucket_days automaticamente. - SaasCreditsRevenueCard.vue: 4 KPIs (receita + ticket médio, compras + tenants, créditos vendidos, % consumo global), sparkline SVG com indicador de tendência, ranking top 5 pacotes. - Integrado no SaasDashboard logo antes da tabela "Distribuição por plano". Co-Authored-By: Claude Opus 4.7 (1M context) --- ...000011_saas_whatsapp_credits_analytics.sql | 209 ++++++++++++++++++ .../dashboard/SaasCreditsRevenueCard.vue | 153 +++++++++++++ src/composables/useSaasCreditsAnalytics.js | 82 +++++++ src/views/pages/saas/SaasDashboard.vue | 4 + 4 files changed, 448 insertions(+) create mode 100644 database-novo/migrations/20260423000011_saas_whatsapp_credits_analytics.sql create mode 100644 src/components/dashboard/SaasCreditsRevenueCard.vue create mode 100644 src/composables/useSaasCreditsAnalytics.js diff --git a/database-novo/migrations/20260423000011_saas_whatsapp_credits_analytics.sql b/database-novo/migrations/20260423000011_saas_whatsapp_credits_analytics.sql new file mode 100644 index 0000000..5effafb --- /dev/null +++ b/database-novo/migrations/20260423000011_saas_whatsapp_credits_analytics.sql @@ -0,0 +1,209 @@ +-- ========================================================================== +-- Agencia PSI — Migracao: Analytics SaaS de receita WhatsApp (Grupo 5) +-- ========================================================================== +-- 4 funcoes RPC pra popular cards novos no SaasDashboard: +-- - saas_wa_credits_revenue_stats(from, to) → agregados globais +-- - saas_wa_credits_top_packages(from, to) → ranking de pacotes +-- - saas_wa_credits_usage_summary(from, to) → vendidos vs usados +-- - saas_wa_credits_revenue_evolution(from,to,bucket) → serie temporal +-- +-- Fonte: whatsapp_credit_purchases (paid) + whatsapp_credits_balance/ +-- whatsapp_credits_transactions. +-- +-- Apenas saas_admin pode chamar (RLS da policy + SECURITY DEFINER check). +-- ========================================================================== + +-- --------------------------------------------------------------------------- +-- saas_wa_credits_revenue_stats: totais do periodo +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.saas_wa_credits_revenue_stats( + p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'), + p_to TIMESTAMPTZ DEFAULT now() +) +RETURNS TABLE ( + revenue_brl NUMERIC, + purchases_count INT, + tenants_count INT, + credits_sold INT, + avg_ticket_brl NUMERIC +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NOT public.is_saas_admin() THEN + RAISE EXCEPTION 'permission_denied'; + END IF; + + RETURN QUERY + SELECT + COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl, + COUNT(*)::INT AS purchases_count, + COUNT(DISTINCT p.tenant_id)::INT AS tenants_count, + COALESCE(SUM(p.credits), 0)::INT AS credits_sold, + CASE WHEN COUNT(*) = 0 THEN 0 + ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2) + END AS avg_ticket_brl + FROM public.whatsapp_credit_purchases p + WHERE p.status = 'paid' + AND p.paid_at >= p_from + AND p.paid_at <= p_to; +END; +$$; + +REVOKE ALL ON FUNCTION public.saas_wa_credits_revenue_stats(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.saas_wa_credits_revenue_stats(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated, service_role; + + +-- --------------------------------------------------------------------------- +-- saas_wa_credits_top_packages: ranking dos pacotes mais vendidos +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.saas_wa_credits_top_packages( + p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'), + p_to TIMESTAMPTZ DEFAULT now() +) +RETURNS TABLE ( + package_id UUID, + package_name TEXT, + purchases_count INT, + revenue_brl NUMERIC, + credits_sold INT +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NOT public.is_saas_admin() THEN + RAISE EXCEPTION 'permission_denied'; + END IF; + + RETURN QUERY + SELECT + p.package_id, + -- Nome snapshot do momento da compra; se tem package_id, usa o nome + -- atual pra consolidar pacotes renomeados + COALESCE( + (SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id), + p.package_name + ) AS package_name, + COUNT(*)::INT AS purchases_count, + SUM(p.amount_brl)::NUMERIC AS revenue_brl, + SUM(p.credits)::INT AS credits_sold + FROM public.whatsapp_credit_purchases p + WHERE p.status = 'paid' + AND p.paid_at >= p_from + AND p.paid_at <= p_to + GROUP BY p.package_id, p.package_name + ORDER BY revenue_brl DESC + LIMIT 10; +END; +$$; + +REVOKE ALL ON FUNCTION public.saas_wa_credits_top_packages(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.saas_wa_credits_top_packages(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated, service_role; + + +-- --------------------------------------------------------------------------- +-- saas_wa_credits_usage_summary: vendidos vs usados (snapshot atual) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.saas_wa_credits_usage_summary() +RETURNS TABLE ( + lifetime_purchased INT, + lifetime_used INT, + current_balance INT, + usage_rate NUMERIC, + tenants_with_balance INT +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NOT public.is_saas_admin() THEN + RAISE EXCEPTION 'permission_denied'; + END IF; + + RETURN QUERY + SELECT + COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased, + COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used, + COALESCE(SUM(balance), 0)::INT AS current_balance, + CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0 + ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1) + END AS usage_rate, + COUNT(*)::INT AS tenants_with_balance + FROM public.whatsapp_credits_balance; +END; +$$; + +REVOKE ALL ON FUNCTION public.saas_wa_credits_usage_summary() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.saas_wa_credits_usage_summary() TO authenticated, service_role; + + +-- --------------------------------------------------------------------------- +-- saas_wa_credits_revenue_evolution: serie temporal +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.saas_wa_credits_revenue_evolution( + p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'), + p_to TIMESTAMPTZ DEFAULT now(), + p_bucket_days INT DEFAULT 7 +) +RETURNS TABLE ( + bucket_start TIMESTAMPTZ, + purchases_count INT, + revenue_brl NUMERIC +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NOT public.is_saas_admin() THEN + RAISE EXCEPTION 'permission_denied'; + END IF; + + RETURN QUERY + WITH purchases AS ( + SELECT p.paid_at, p.amount_brl + FROM public.whatsapp_credit_purchases p + WHERE p.status = 'paid' + AND p.paid_at >= p_from + AND p.paid_at <= p_to + ), + bucketed AS ( + SELECT + p_from + ( + FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT + * p_bucket_days * interval '1 day' + ) AS bucket_start, + amount_brl + FROM purchases + ) + SELECT + bucket_start, + COUNT(*)::INT AS purchases_count, + SUM(amount_brl)::NUMERIC AS revenue_brl + FROM bucketed + GROUP BY bucket_start + ORDER BY bucket_start; +END; +$$; + +REVOKE ALL ON FUNCTION public.saas_wa_credits_revenue_evolution(TIMESTAMPTZ, TIMESTAMPTZ, INT) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.saas_wa_credits_revenue_evolution(TIMESTAMPTZ, TIMESTAMPTZ, INT) TO authenticated, service_role; + + +COMMENT ON FUNCTION public.saas_wa_credits_revenue_stats(TIMESTAMPTZ, TIMESTAMPTZ) IS + 'Totais de receita WhatsApp creditos no periodo (saas_admin).'; +COMMENT ON FUNCTION public.saas_wa_credits_top_packages(TIMESTAMPTZ, TIMESTAMPTZ) IS + 'Ranking pacotes mais vendidos no periodo (saas_admin).'; +COMMENT ON FUNCTION public.saas_wa_credits_usage_summary() IS + 'Snapshot atual: vendidos lifetime vs usados vs saldo (saas_admin).'; +COMMENT ON FUNCTION public.saas_wa_credits_revenue_evolution(TIMESTAMPTZ, TIMESTAMPTZ, INT) IS + 'Serie temporal de receita em buckets de N dias (saas_admin).'; diff --git a/src/components/dashboard/SaasCreditsRevenueCard.vue b/src/components/dashboard/SaasCreditsRevenueCard.vue new file mode 100644 index 0000000..24a0da3 --- /dev/null +++ b/src/components/dashboard/SaasCreditsRevenueCard.vue @@ -0,0 +1,153 @@ + + + + diff --git a/src/composables/useSaasCreditsAnalytics.js b/src/composables/useSaasCreditsAnalytics.js new file mode 100644 index 0000000..3568e83 --- /dev/null +++ b/src/composables/useSaasCreditsAnalytics.js @@ -0,0 +1,82 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/composables/useSaasCreditsAnalytics.js +| +| Wrapper das RPCs do Dashboard SaaS pra analytics de créditos WhatsApp: +| - saas_wa_credits_revenue_stats (agregados do período) +| - saas_wa_credits_top_packages (ranking) +| - saas_wa_credits_usage_summary (vendidos vs usados — snapshot) +| - saas_wa_credits_revenue_evolution (série temporal) +|-------------------------------------------------------------------------- +*/ + +import { ref, computed } from 'vue'; +import { supabase } from '@/lib/supabase/client'; + +const PERIOD_DAYS = { '30d': 30, '90d': 90, '180d': 180, '365d': 365 }; +const BUCKET_BY_PERIOD = { '30d': 7, '90d': 15, '180d': 30, '365d': 30 }; + +export function useSaasCreditsAnalytics() { + const stats = ref(null); + const topPackages = ref([]); + const usage = ref(null); + const evolution = ref([]); + const loading = ref(false); + const error = ref(null); + + async function loadAll({ period = '30d' } = {}) { + const days = PERIOD_DAYS[period] || 30; + const bucketDays = BUCKET_BY_PERIOD[period] || 7; + const to = new Date(); + const from = new Date(Date.now() - days * 24 * 3600 * 1000); + + loading.value = true; + error.value = null; + try { + const [sRes, tRes, uRes, eRes] = await Promise.all([ + supabase.rpc('saas_wa_credits_revenue_stats', { p_from: from.toISOString(), p_to: to.toISOString() }), + supabase.rpc('saas_wa_credits_top_packages', { p_from: from.toISOString(), p_to: to.toISOString() }), + supabase.rpc('saas_wa_credits_usage_summary'), + supabase.rpc('saas_wa_credits_revenue_evolution', { p_from: from.toISOString(), p_to: to.toISOString(), p_bucket_days: bucketDays }) + ]); + + if (sRes.error) throw sRes.error; + if (tRes.error) throw tRes.error; + if (uRes.error) throw uRes.error; + if (eRes.error) throw eRes.error; + + stats.value = Array.isArray(sRes.data) ? (sRes.data[0] || null) : sRes.data; + topPackages.value = tRes.data || []; + usage.value = Array.isArray(uRes.data) ? (uRes.data[0] || null) : uRes.data; + evolution.value = eRes.data || []; + } catch (e) { + error.value = e; + stats.value = null; + topPackages.value = []; + usage.value = null; + evolution.value = []; + } finally { + loading.value = false; + } + } + + function formatBRL(v) { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(v) || 0); + } + + const hasRevenue = computed(() => (Number(stats.value?.revenue_brl) || 0) > 0); + + return { + stats, + topPackages, + usage, + evolution, + loading, + error, + hasRevenue, + loadAll, + formatBRL + }; +} diff --git a/src/views/pages/saas/SaasDashboard.vue b/src/views/pages/saas/SaasDashboard.vue index 90eceb9..a6947f2 100644 --- a/src/views/pages/saas/SaasDashboard.vue +++ b/src/views/pages/saas/SaasDashboard.vue @@ -23,6 +23,7 @@ import { useToast } from 'primevue/usetoast'; import { useConfirm } from 'primevue/useconfirm'; import Chart from 'primevue/chart'; +import SaasCreditsRevenueCard from '@/components/dashboard/SaasCreditsRevenueCard.vue'; const router = useRouter(); const toast = useToast(); @@ -604,6 +605,9 @@ onBeforeUnmount(() => { + + +
Distribuição por plano