Copyright, Financeiro, Lançamentos, aprimoramentos de ui

This commit is contained in:
Leonardo
2026-03-21 08:05:40 -03:00
parent 29ed349cf2
commit a89d1f5560
268 changed files with 58870 additions and 1752 deletions
+271
View File
@@ -0,0 +1,271 @@
/*
|--------------------------------------------------------------------------
| 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<Object|null>}
*/
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<void>
* @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 }
}
// ── remarcar → atualizar due_date da cobrança existente ────────────
if (novoStatus === 'remarcar' && 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,
}
}