diff --git a/database-novo/migrations/20260423000001_admin_adjust_whatsapp_credits.sql b/database-novo/migrations/20260423000001_admin_adjust_whatsapp_credits.sql
new file mode 100644
index 0000000..ceec1df
--- /dev/null
+++ b/database-novo/migrations/20260423000001_admin_adjust_whatsapp_credits.sql
@@ -0,0 +1,190 @@
+-- ==========================================================================
+-- 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().';
diff --git a/src/views/pages/saas/SaasAddonsPage.vue b/src/views/pages/saas/SaasAddonsPage.vue
index a31a7bf..4edace8 100644
--- a/src/views/pages/saas/SaasAddonsPage.vue
+++ b/src/views/pages/saas/SaasAddonsPage.vue
@@ -386,14 +386,88 @@ function deleteWaPkg(row) {
});
}
+const waPkgToggleBump = ref(0);
+
+function escapeHtml(s) {
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
async function toggleWaPkgActive(row) {
- try {
- const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: !row.is_active }).eq('id', row.id);
- if (error) throw error;
- row.is_active = !row.is_active;
- } catch (e) {
- toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
+ // Ativar: direto, sem confirm
+ if (!row.is_active) {
+ try {
+ const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: true }).eq('id', row.id);
+ if (error) throw error;
+ row.is_active = true;
+ toast.add({ severity: 'success', summary: 'Pacote ativado', life: 2000 });
+ } catch (e) {
+ waPkgToggleBump.value++;
+ toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
+ }
+ return;
}
+
+ // Desativar: força revert visual até o usuário decidir
+ waPkgToggleBump.value++;
+
+ // Computa impacto e pede confirm
+ let purchasesCount = 0;
+ let tenantsCount = 0;
+ let otherActiveCount = 0;
+ try {
+ const [{ count: pc }, { data: purchasesTenants }, { count: oa }] = await Promise.all([
+ supabase
+ .from('whatsapp_credit_purchases')
+ .select('id', { count: 'exact', head: true })
+ .eq('package_id', row.id),
+ supabase
+ .from('whatsapp_credit_purchases')
+ .select('tenant_id')
+ .eq('package_id', row.id),
+ supabase
+ .from('whatsapp_credit_packages')
+ .select('id', { count: 'exact', head: true })
+ .eq('is_active', true)
+ .neq('id', row.id)
+ ]);
+ purchasesCount = pc || 0;
+ tenantsCount = new Set((purchasesTenants || []).map((r) => r.tenant_id)).size;
+ otherActiveCount = oa || 0;
+ } catch (e) {
+ toast.add({ severity: 'warn', summary: 'Impacto indisponível', detail: 'Não foi possível calcular histórico — confirme com atenção.', life: 3500 });
+ }
+
+ const impactLine =
+ purchasesCount > 0
+ ? `Este pacote foi comprado ${purchasesCount} ${purchasesCount === 1 ? 'vez' : 'vezes'} por ${tenantsCount} ${tenantsCount === 1 ? 'tenant' : 'tenants distintos'}.`
+ : 'Este pacote nunca foi comprado até agora.';
+
+ const items = [
+ `Desativar "${escapeHtml(row.name)}" vai removê-lo da loja pros tenants.`,
+ impactLine,
+ 'Créditos já adquiridos continuam válidos — apenas o pacote some da vitrine.'
+ ];
+ if (otherActiveCount === 0) {
+ items.push('⚠️ ATENÇÃO: este é o único pacote ativo. A loja de créditos ficará vazia até você ativar outro.');
+ }
+
+ confirm.require({
+ group: 'headless',
+ header: `Desativar "${row.name}"?`,
+ message: items.join('
'),
+ icon: otherActiveCount === 0 ? 'pi-exclamation-triangle' : 'pi-eye-slash',
+ color: otherActiveCount === 0 ? '#f59e0b' : '#ef4444',
+ accept: async () => {
+ try {
+ const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: false }).eq('id', row.id);
+ if (error) throw error;
+ row.is_active = false;
+ toast.add({ severity: 'success', summary: 'Pacote desativado', life: 2000 });
+ } catch (e) {
+ toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
+ }
+ }
+ });
}
function formatBrl(v) {
@@ -401,33 +475,41 @@ function formatBrl(v) {
}
// ══════════════════════════════════════════════════════════════
-// ABA 5 — WhatsApp: Topup manual (add_whatsapp_credits RPC)
+// ABA 5 — WhatsApp: Ajuste manual SaaS admin (admin_adjust_whatsapp_credits RPC)
// ══════════════════════════════════════════════════════════════
const waTopup = ref({
tenantId: null,
amount: 100,
- kind: 'topup_manual',
+ mode: 'add', // 'add' ou 'remove'
note: ''
});
-const waTopupKinds = [
- { label: 'Topup manual (cortesia)', value: 'topup_manual' },
- { label: 'Ajuste', value: 'adjustment' },
- { label: 'Estorno / Refund', value: 'refund' }
+const waTopupModes = [
+ { label: 'Adicionar', value: 'add', icon: 'pi pi-plus' },
+ { label: 'Remover', value: 'remove', icon: 'pi pi-minus' }
];
+const WA_ADJUST_MAX = 1000;
+
const waTenantBalance = ref(null);
+const waRemovable = ref(null); // { balance, removable, protected_amount, topup_net, usage_total }
const waRecentTopups = ref([]);
const waTopupSaving = ref(false);
async function loadWaBalance(tenantId) {
- if (!tenantId) { waTenantBalance.value = null; waRecentTopups.value = []; return; }
- const [{ data: bal }, { data: txs }] = await Promise.all([
+ if (!tenantId) {
+ waTenantBalance.value = null;
+ waRemovable.value = null;
+ waRecentTopups.value = [];
+ return;
+ }
+ const [{ data: bal }, { data: rem, error: remErr }, { data: txs }] = await Promise.all([
supabase
.from('whatsapp_credits_balance')
.select('balance, lifetime_purchased, lifetime_used, low_balance_threshold')
.eq('tenant_id', tenantId)
.maybeSingle(),
+ supabase.rpc('get_whatsapp_removable_balance', { p_tenant_id: tenantId }),
supabase
.from('whatsapp_credits_transactions')
.select('id, kind, amount, balance_after, note, created_at, admin_id')
@@ -437,6 +519,7 @@ async function loadWaBalance(tenantId) {
.limit(20)
]);
waTenantBalance.value = bal || { balance: 0, lifetime_purchased: 0, lifetime_used: 0, low_balance_threshold: 20 };
+ waRemovable.value = Array.isArray(rem) && rem.length ? rem[0] : (remErr ? null : { balance: bal?.balance || 0, removable: 0, protected_amount: bal?.balance || 0, topup_net: 0, usage_total: 0 });
waRecentTopups.value = txs || [];
}
@@ -444,37 +527,89 @@ async function onWaTenantChange() {
await loadWaBalance(waTopup.value.tenantId);
}
-async function submitWaTopup() {
+function waMaxAmountForMode() {
+ if (waTopup.value.mode === 'remove') {
+ return Math.min(WA_ADJUST_MAX, Math.max(0, waRemovable.value?.removable || 0));
+ }
+ return WA_ADJUST_MAX;
+}
+
+async function doWaAdjust() {
const t = waTopup.value;
- if (!t.tenantId) { toast.add({ severity: 'warn', summary: 'Selecione o tenant', life: 2500 }); return; }
- const amt = Math.round(Number(t.amount) || 0);
- if (amt < 1) { toast.add({ severity: 'warn', summary: 'Créditos deve ser >= 1', life: 2500 }); return; }
+ const amtAbs = Math.round(Number(t.amount) || 0);
const note = String(t.note || '').trim().slice(0, 500) || null;
+ const signed = t.mode === 'remove' ? -amtAbs : amtAbs;
waTopupSaving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const adminId = authData?.user?.id || null;
- const { error } = await supabase.rpc('add_whatsapp_credits', {
+ if (!adminId) throw new Error('Sessão inválida — refaça login');
+
+ const { error } = await supabase.rpc('admin_adjust_whatsapp_credits', {
p_tenant_id: t.tenantId,
- p_amount: amt,
- p_kind: t.kind,
- p_purchase_id: null,
+ p_amount: signed,
p_admin_id: adminId,
p_note: note
});
if (error) throw error;
- toast.add({ severity: 'success', summary: `+${amt} créditos`, detail: tenantName(t.tenantId), life: 3000 });
+
+ const sign = signed > 0 ? '+' : '';
+ toast.add({
+ severity: 'success',
+ summary: `${sign}${signed} créditos`,
+ detail: tenantName(t.tenantId),
+ life: 3000
+ });
waTopup.value.note = '';
waTopup.value.amount = 100;
await loadWaBalance(t.tenantId);
} catch (e) {
- toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
+ const raw = e?.message || '';
+ let friendly = raw;
+ if (raw.includes('amount_exceeds_limit')) friendly = 'Máximo 1000 por operação.';
+ else if (raw.includes('cannot_remove_beyond_removable')) {
+ const match = raw.match(/max=(\d+)/);
+ friendly = match ? `Só é possível remover até ${match[1]} créditos (o restante é compra protegida).` : 'Saldo removível insuficiente.';
+ } else if (raw.includes('tenant_has_no_balance')) friendly = 'Tenant ainda não tem saldo.';
+ else if (raw.includes('permission_denied')) friendly = 'Permissão negada — apenas saas_admin.';
+ toast.add({ severity: 'error', summary: 'Erro', detail: friendly, life: 5000 });
} finally {
waTopupSaving.value = false;
}
}
+function submitWaTopup() {
+ const t = waTopup.value;
+ if (!t.tenantId) { toast.add({ severity: 'warn', summary: 'Selecione o tenant', life: 2500 }); return; }
+ const amt = Math.round(Number(t.amount) || 0);
+ if (amt < 1) { toast.add({ severity: 'warn', summary: 'Valor deve ser >= 1', life: 2500 }); return; }
+ if (amt > WA_ADJUST_MAX) { toast.add({ severity: 'warn', summary: `Máximo ${WA_ADJUST_MAX} por operação`, life: 3000 }); return; }
+
+ if (t.mode === 'remove') {
+ const max = waMaxAmountForMode();
+ if (max <= 0) { toast.add({ severity: 'warn', summary: 'Nada removível', detail: 'Este tenant não tem créditos de cortesia disponíveis pra remoção.', life: 4000 }); return; }
+ if (amt > max) { toast.add({ severity: 'warn', summary: `Máximo removível: ${max}`, life: 3000 }); return; }
+
+ const msgItems = [
+ `Vai subtrair ${amt} créditos de ${escapeHtml(tenantName(t.tenantId))} (pool cortesia).`,
+ `Saldo resultante: ${(waTenantBalance.value?.balance || 0) - amt}.`,
+ 'Créditos comprados continuam protegidos.',
+ 'Esta operação fica registrada no extrato com seu user_id.'
+ ];
+ confirm.require({
+ group: 'headless',
+ header: `Remover ${amt} créditos?`,
+ message: msgItems.join('
'),
+ icon: 'pi-minus-circle',
+ color: '#ef4444',
+ accept: () => doWaAdjust()
+ });
+ } else {
+ doWaAdjust();
+ }
+}
+
const waKindBadge = {
topup_manual: { label: 'Topup', cls: 'bg-sky-500/10 text-sky-600' },
adjustment: { label: 'Ajuste', cls: 'bg-slate-500/10 text-slate-600' },
@@ -517,7 +652,7 @@ onMounted(() => {
{{ message.header }}
-
{{ message.message }}
+
- Ações: cortesia onboarding, reembolso fora do Asaas, correção de falha técnica. Fica no extrato do tenant
- com admin_id = você pra auditoria.
+ Adicionar (cortesia onboarding, ressarcimento, correção de falha) ou remover (corrigir topup errado).
+ Limite de 1000 por operação · remoção só afeta créditos de cortesia, nunca compras.
+ Fica no extrato com admin_id = você.