7c20b518d4
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
11 KiB
JavaScript
256 lines
11 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| 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 };
|
|
}
|
|
|
|
// ── 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
|
|
};
|
|
}
|