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 }}

+