Admin SaaS: ajuste manual de créditos WhatsApp (+/-) com proteção de compras
Fecha polimento do Marco B (créditos/Asaas) entregue em 21/04.
Nova RPC admin_adjust_whatsapp_credits(tenant, amount_signed, admin_id, note):
- |amount| <= 1000 por operação (anti dedo-gordo). Valores maiores → repetir.
- Em remoção (amount < 0), aplica regra FIFO cortesia primeiro:
removable = max(0, sum(topup_manual+adjustment+refund) - usage_total).
Créditos de 'purchase' (Asaas/PIX) são intocáveis — estorno real vai pelo
fluxo financeiro do Asaas.
- Protegida por is_saas_admin() — authenticated comum não consegue chamar.
- Registra como kind='adjustment' com amount signed (+ ou -).
Helper get_whatsapp_removable_balance(tenant) retorna {balance, removable,
protected_amount, topup_net, usage_total} pra UI mostrar breakdown.
Aba 4 (Pacotes WhatsApp):
- Desativação dispara ConfirmDialog com histórico (N compras, M tenants
distintos) + aviso forte se é o único pacote ativo + nota que créditos já
adquiridos continuam válidos.
- Fix visual: :key no ToggleSwitch força re-mount durante confirm pra não
desligar visualmente antes do accept.
Aba 5 (Topup → Ajuste):
- Substituído Select de kind por SelectButton Adicionar/Remover.
- InputNumber max 1000 · label e botão dinâmicos.
- Modo Remover: card laranja com breakdown removível/protegido, botão
vermelho, confirm obrigatório com saldo resultante.
- Error mapping friendly pt-BR pros códigos da RPC.
ConfirmDialog com v-html habilitado pra suportar <br><br> entre frases
e <strong>/cores. Inputs livres (row.name, tenantName) passam por
escapeHtml() antes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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().';
|
||||
Reference in New Issue
Block a user