/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Criado e desenvolvido por Leonardo Nohama | | Tecnologia aplicada à escuta. | Estrutura para o cuidado. | | Arquivo: src/composables/useAgendaFinanceiro.js | Data: 2026 | Local: São Carlos/SP — Brasil |-------------------------------------------------------------------------- | © 2026 — Todos os direitos reservados |-------------------------------------------------------------------------- */ /** * useAgendaFinanceiro * * Camada de orquestração entre agenda e financeiro. * Não modifica useAgendaEvents diretamente — recebe a função de update * como parâmetro para manter o desacoplamento. * * Uso: * const { handleStatusChange, gerarCobrancaManual } = useAgendaFinanceiro() * * // No handler de save do componente pai: * await handleStatusChange(eventoOriginal, novoStatus, agendaEvents.update) */ import { ref } from 'vue'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; // ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─ // Chave: `${tenantId}:${exceptionType}` → FinancialException | null const _exceptionsCache = new Map(); // ─── helper ────────────────────────────────────────────────────────────────── async function getUid() { const { data, error } = await supabase.auth.getUser(); if (error) throw error; const uid = data?.user?.id; if (!uid) throw new Error('Usuário não autenticado.'); return uid; } // ─── mapeamento: status anterior → tipo de exceção a consultar ─────────────── const STATUS_TO_EXCEPTION = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' }; // ─── calcular valor cobrado por charge_mode ─────────────────────────────────── function calcChargeAmount(originalAmount, rule) { if (!rule || rule.charge_mode === 'none') return 0; if (rule.charge_mode === 'full') return originalAmount; if (rule.charge_mode === 'fixed') return rule.charge_value ?? 0; if (rule.charge_mode === 'percentage') { const pct = rule.charge_pct ?? 0; return parseFloat(((originalAmount * pct) / 100).toFixed(2)); } return originalAmount; } // ─── composable ────────────────────────────────────────────────────────────── export function useAgendaFinanceiro() { const loading = ref(false); const error = ref(null); // ── getFinancialExceptionRule ───────────────────────────────────────────── /** * Busca a regra de exceção financeira para um tipo, com cache em memória. * Prioridade: regra própria do owner > regra global do tenant (owner_id IS NULL) * * @param {string} tenantId * @param {'patient_no_show'|'patient_cancellation'|'professional_cancellation'} exceptionType * @returns {Promise} */ async function getFinancialExceptionRule(tenantId, exceptionType) { const cacheKey = `${tenantId}:${exceptionType}`; if (_exceptionsCache.has(cacheKey)) return _exceptionsCache.get(cacheKey); const uid = await getUid(); const { data, error: err } = await supabase .from('financial_exceptions') .select('*') .eq('tenant_id', tenantId) .eq('exception_type', exceptionType) .or(`owner_id.eq.${uid},owner_id.is.null`) .order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade .limit(1) .maybeSingle(); if (err) { console.warn('[useAgendaFinanceiro] getFinancialExceptionRule:', err.message); return null; } _exceptionsCache.set(cacheKey, data ?? null); return data ?? null; } // ── gerarCobrancaManual ─────────────────────────────────────────────────── /** * Gera cobrança para uma sessão existente com `billed = false`. * Chama a RPC `create_financial_record_for_session`. * * @param {Object} evento - linha de agenda_eventos (com campo price) * @returns {Promise<{ok: boolean, data?: Object, error?: string}>} */ async function gerarCobrancaManual(evento) { if (evento.billing_contract_id) { // sessão de pacote — não gera cobrança individual return { ok: false, error: 'Sessão faz parte de um pacote. Cobrança gerenciada pelo contrato.' }; } const tenantStore = useTenantStore(); const tenantId = tenantStore.activeTenantId; if (!tenantId) return { ok: false, error: 'Tenant não identificado.' }; const ownerId = await getUid(); loading.value = true; error.value = null; try { const amount = evento.price ?? 0; const dueDate = evento.inicio_em ? new Date(evento.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const { data, error: err } = await supabase.rpc('create_financial_record_for_session', { p_tenant_id: tenantId, p_owner_id: ownerId, p_patient_id: evento.patient_id ?? evento.paciente_id ?? null, p_agenda_evento_id: evento.id, p_amount: amount, p_due_date: dueDate }); if (err) throw err; return { ok: true, data }; } catch (e) { error.value = e?.message || 'Erro ao gerar cobrança.'; return { ok: false, error: error.value }; } finally { loading.value = false; } } // ── handleStatusChange ──────────────────────────────────────────────────── /** * Orquestra a mudança de status de uma sessão + consequências financeiras. * * @param {Object} evento - linha atual de agenda_eventos (ANTES da mudança) * @param {string} novoStatus - novo status a aplicar * @param {Function} agendaUpdateFn - função que aplica o update na agenda (ex: agendaEvents.update) * signature: (id, patch) => Promise * @returns {Promise<{ok: boolean, error?: string}>} */ async function handleStatusChange(evento, novoStatus, agendaUpdateFn) { // bloqueios e sessões de pacote não têm cobrança individual const ignorar = evento.tipo !== 'sessao' || !!evento.billing_contract_id; const statusAnterior = evento.status; loading.value = true; error.value = null; try { // 1. Aplica o update na agenda sempre (fonte da verdade é a agenda) await agendaUpdateFn(evento.id, { status: novoStatus }); if (ignorar) return { ok: true }; if (statusAnterior === novoStatus) return { ok: true }; // 2. Lógica financeira por transição const tenantStore = useTenantStore(); const tenantId = tenantStore.activeTenantId; // ── faltou / cancelado → consultar exceção financeira ────────────── const exceptionType = STATUS_TO_EXCEPTION[novoStatus]; if (exceptionType) { const rule = await getFinancialExceptionRule(tenantId, exceptionType); if (!rule || rule.charge_mode === 'none') { // Cancelar cobrança existente, se houver if (evento.billed) { const { data: existingRec } = await supabase.from('financial_records').select('id, status').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle(); if (existingRec) { await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', existingRec.id); } } return { ok: true }; } // charge_mode != 'none' → ajustar valor da cobrança existente ou criar nova const chargeAmount = calcChargeAmount(evento.price ?? 0, rule); if (evento.billed) { // Atualiza o valor da cobrança existente const { data: existingRec } = await supabase.from('financial_records').select('id').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle(); if (existingRec) { await supabase .from('financial_records') .update({ amount: chargeAmount, final_amount: chargeAmount, updated_at: new Date().toISOString() }) .eq('id', existingRec.id); } } else if (chargeAmount > 0) { // Sessão sem cobrança: gera uma nova com o valor ajustado await gerarCobrancaManual({ ...evento, price: chargeAmount }); } return { ok: true }; } // ── remarcado → atualizar due_date da cobrança existente ──────────── if (novoStatus === 'remarcado' && evento.billed) { // due_date mantém a data da sessão original por enquanto // (a nova data virá quando a sessão for reagendada) return { ok: true }; } // ── agendado → realizado: nenhuma ação financeira automática ──────── return { ok: true }; } catch (e) { error.value = e?.message || 'Erro ao processar mudança de status.'; return { ok: false, error: error.value }; } finally { loading.value = false; } } // ── invalidar cache (use quando o usuário altera exceções financeiras) ─── function invalidateExceptionsCache() { _exceptionsCache.clear(); } return { loading, error, handleStatusChange, gerarCobrancaManual, getFinancialExceptionRule, invalidateExceptionsCache }; }