diff --git a/src/features/financeiro/composables/useBillingContracts.js b/src/features/financeiro/composables/useBillingContracts.js new file mode 100644 index 0000000..f4b7419 --- /dev/null +++ b/src/features/financeiro/composables/useBillingContracts.js @@ -0,0 +1,122 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/composables/useBillingContracts.js +| +| Thin wrapper sobre billingContractsRepository. Operações de pacote/contrato: +| listForPatient, fetchById, create, update, increment/decrement sessions_used. +| +| Gotcha: billing_contracts não tem updated_at — repository já strips. +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { + listForPatient, + getById, + create as repoCreate, + update as repoUpdate, + incrementSessionsUsed, + decrementSessionsUsed +} from '@/features/financeiro/services/billingContractsRepository'; + +export function useBillingContracts() { + const rows = ref([]); + const loading = ref(false); + const error = ref(''); + + async function loadForPatient(patientId, opts = {}) { + if (!patientId) { + rows.value = []; + return; + } + loading.value = true; + error.value = ''; + try { + rows.value = await listForPatient(patientId, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar contratos.'; + rows.value = []; + } finally { + loading.value = false; + } + } + + async function fetchById(contractId, opts = {}) { + try { + return await getById(contractId, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar contrato.'; + return null; + } + } + + async function create(payload) { + loading.value = true; + error.value = ''; + try { + const created = await repoCreate(payload); + rows.value = [created, ...rows.value]; + return created; + } catch (e) { + error.value = e?.message || 'Falha ao criar contrato.'; + throw e; + } finally { + loading.value = false; + } + } + + async function update(contractId, patch, opts = {}) { + loading.value = true; + error.value = ''; + try { + const updated = await repoUpdate(contractId, patch, opts); + const idx = rows.value.findIndex((r) => r.id === contractId); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao atualizar contrato.'; + throw e; + } finally { + loading.value = false; + } + } + + async function consume(contractId, opts = {}) { + error.value = ''; + try { + const updated = await incrementSessionsUsed(contractId, opts); + const idx = rows.value.findIndex((r) => r.id === contractId); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao incrementar saldo.'; + throw e; + } + } + + async function returnSession(contractId, opts = {}) { + error.value = ''; + try { + const updated = await decrementSessionsUsed(contractId, opts); + const idx = rows.value.findIndex((r) => r.id === contractId); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao devolver saldo.'; + throw e; + } + } + + return { + rows, + loading, + error, + loadForPatient, + fetchById, + create, + update, + consume, + returnSession + }; +} diff --git a/src/features/financeiro/composables/useBillingOrchestrator.js b/src/features/financeiro/composables/useBillingOrchestrator.js new file mode 100644 index 0000000..edc28a5 --- /dev/null +++ b/src/features/financeiro/composables/useBillingOrchestrator.js @@ -0,0 +1,145 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/composables/useBillingOrchestrator.js +| +| ORCHESTRATOR central de billing — single entry point pra qualquer mudança +| de cobrança vinculada a evento da agenda. Resolve overlap entre os 3 +| caminhos atuais (useAgendaFinanceiro.gerarCobrancaManual, handleStatusChange, +| useMelissaAgenda._applyStatusDecisions). +| +| ⚠️ FOUNDATION em construção. Decisões #2/#3/#6 (memória project_agenda_billing_decisoes) +| ainda pendentes — state machine completa fica pra sessão dedicada antes do +| rollout dos callers. +| +| Design completo: development/02-auditoria/DESIGN_BILLING_ORCHESTRATOR.md +| +| Plano de migração (Fases A-D do design doc): +| [x] Fase A — repositories criados (financialRecords, financialExceptions, billingContracts) +| [x] Fase B — composables thin wrappers criados (useFinancialRecords, useFinancialExceptions, useBillingContracts, este orchestrator) +| [ ] Fase C — state machine completa + migração dos 3 callers (BLOQUEADA pelas decisões #2/#3/#6) +| [ ] Fase D — cleanup (deletar src/composables/useFinancialRecords.js + useAgendaFinanceiro.js) +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { useFinancialRecords } from './useFinancialRecords'; +import { useFinancialExceptions } from './useFinancialExceptions'; +import { useBillingContracts } from './useBillingContracts'; + +export function useBillingOrchestrator() { + const loading = ref(false); + const error = ref(''); + + const financialRecords = useFinancialRecords(); + const exceptions = useFinancialExceptions(); + const contracts = useBillingContracts(); + + /** + * Resolve state atual do evento (snapshot pra decisões da transição). + * @returns {Promise<{records: Array, packageInfo: Object|null}>} + */ + async function resolveBillingState(eventId, { billing_contract_id } = {}) { + const records = await financialRecords.fetchByEvent(eventId); + const packageInfo = billing_contract_id ? await contracts.fetchById(billing_contract_id) : null; + return { records, packageInfo }; + } + + /** + * Gera cobrança manual pra evento sem cobrança ainda. Idempotente + * (RPC create_financial_record_for_session ignora cancelled). + */ + async function generateChargeForEvent(event, options = {}) { + if (event.billing_contract_id) { + return { ok: false, error: 'Sessão faz parte de um pacote. Cobrança gerenciada pelo contrato.' }; + } + loading.value = true; + error.value = ''; + try { + const amount = options.amount ?? event.price ?? 0; + const dueDate = options.due_date || (event.inicio_em ? new Date(event.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10)); + const data = await financialRecords.createRecord({ + patient_id: event.patient_id ?? event.paciente_id ?? null, + agenda_evento_id: event.id, + amount, + due_date: dueDate + }); + return data; + } catch (e) { + error.value = e?.message || 'Falha ao gerar cobrança.'; + return { ok: false, error: error.value }; + } finally { + loading.value = false; + } + } + + /** + * Lista records de um evento. Helper público pra UI. + */ + async function fetchRecordsForEvent(eventId) { + return financialRecords.fetchByEvent(eventId); + } + + /** + * Cancela TODOS os records pending/overdue de um evento (soft). + * Use em reverse transitions confirmadas pelo user. + */ + async function cancelRecordsForEvent(eventId, reason) { + const records = await financialRecords.fetchByEvent(eventId); + const cancelable = records.filter((r) => ['pending', 'overdue'].includes(r.status)); + const results = []; + for (const r of cancelable) { + const res = await financialRecords.cancelRecord(r.id, { reason }); + results.push({ id: r.id, ...res }); + } + return results; + } + + /** + * 🚧 STATE MACHINE — implementação completa BLOQUEADA pelas decisões #2/#3/#6. + * + * Estrutura prevista (do DESIGN_BILLING_ORCHESTRATOR.md): + * + * applyStatusChange({ event, fromStatus, toStatus, decisions? }) + * → { ok, actions: [...], needsConfirmation?, error? } + * + * Transições: + * agendado→realizado | agendado→faltou | agendado→cancelado + * realizado→agendado (REVERSE) | realizado→faltou (CROSS) + * faltou→agendado (REVERSE) | cancelado→agendado (REVERSE) + * + * Quando completar: + * 1. Migrar callers (AgendaEventDialog, useMelissaAgenda._applyStatusDecisions, + * callers de gerarCobrancaManual) + * 2. Deletar src/composables/useFinancialRecords.js + * 3. Deletar src/composables/useAgendaFinanceiro.js + */ + async function applyStatusChange(_params) { + throw new Error( + 'applyStatusChange ainda não implementado. ' + + 'Decisões #2/#3/#6 de billing pendentes (memória project_agenda_billing_decisoes). ' + + 'Ver DESIGN_BILLING_ORCHESTRATOR.md seção 7.4. ' + + 'Caller deve continuar usando useAgendaFinanceiro.handleStatusChange até Fase C estar pronta.' + ); + } + + function invalidateRules() { + exceptions.invalidate(); + } + + return { + loading, + error, + // Sub-composables (acesso direto se precisar de algo específico) + financialRecords, + exceptions, + contracts, + // High-level operations + resolveBillingState, + generateChargeForEvent, + fetchRecordsForEvent, + cancelRecordsForEvent, + applyStatusChange, + invalidateRules + }; +} diff --git a/src/features/financeiro/composables/useFinancialExceptions.js b/src/features/financeiro/composables/useFinancialExceptions.js new file mode 100644 index 0000000..6f8bb2f --- /dev/null +++ b/src/features/financeiro/composables/useFinancialExceptions.js @@ -0,0 +1,90 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/composables/useFinancialExceptions.js +| +| Cache de regras de exceção financeira POR INSTÂNCIA do composable. +| Substitui o _exceptionsCache módulo-level do useAgendaFinanceiro.js +| (que vazava entre instâncias — audit baseline alta). +| +| Cache TTL: vive enquanto o composable existir. Chamar invalidate() +| ao trocar tenant. +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { + getRule, + listAll, + upsertRule, + calcChargeAmount +} from '@/features/financeiro/services/financialExceptionsRepository'; + +export function useFinancialExceptions() { + const rules = ref([]); + const loading = ref(false); + const error = ref(''); + + // Cache local — chave: `${tenantId}:${exceptionType}` + const _cache = new Map(); + + async function getRuleCached(exceptionType, { tenantId } = {}) { + const key = `${tenantId || 'active'}:${exceptionType}`; + if (_cache.has(key)) return _cache.get(key); + try { + const rule = await getRule(exceptionType, { tenantId }); + _cache.set(key, rule); + return rule; + } catch (e) { + error.value = e?.message || 'Falha ao carregar regra de exceção.'; + return null; + } + } + + async function loadAll(opts = {}) { + loading.value = true; + error.value = ''; + try { + rules.value = await listAll(opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar regras.'; + rules.value = []; + } finally { + loading.value = false; + } + } + + async function upsert(payload) { + loading.value = true; + error.value = ''; + try { + const updated = await upsertRule(payload); + const idx = rules.value.findIndex((r) => r.id === updated.id); + if (idx >= 0) rules.value[idx] = updated; + else rules.value = [...rules.value, updated]; + invalidate(); + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao salvar regra.'; + throw e; + } finally { + loading.value = false; + } + } + + function invalidate() { + _cache.clear(); + } + + return { + rules, + loading, + error, + getRuleCached, + loadAll, + upsert, + invalidate, + // re-export utilitário (puro, não state) + calcChargeAmount + }; +} diff --git a/src/features/financeiro/composables/useFinancialRecords.js b/src/features/financeiro/composables/useFinancialRecords.js new file mode 100644 index 0000000..8a6def9 --- /dev/null +++ b/src/features/financeiro/composables/useFinancialRecords.js @@ -0,0 +1,211 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/composables/useFinancialRecords.js +| +| Thin wrapper sobre financialRecordsRepository (composable-blueprint Tipo A). +| Substitui src/composables/useFinancialRecords.js quando callers migrarem. +| +| Mantém as mesmas funções públicas + computeds (summary) — drop-in +| replacement quando hora chegar. +|-------------------------------------------------------------------------- +*/ +import { ref, computed } from 'vue'; +import { + list as repoList, + getById, + listByEvent, + createForSession, + createManual, + markAsPaid as repoMarkAsPaid, + markAsUnpaid as repoMarkAsUnpaid, + cancel as repoCancel, + update as repoUpdate +} from '@/features/financeiro/services/financialRecordsRepository'; + +export function useFinancialRecords() { + const records = ref([]); + const total = ref(0); + const loading = ref(false); + const error = ref(''); + + // ── computed: resumo financeiro ────────────────────────────────────── + const summary = computed(() => { + const now = new Date(); + const thisYear = now.getFullYear(); + const thisMonth = now.getMonth(); + + const countByStatus = { pending: 0, paid: 0, overdue: 0, cancelled: 0 }; + let totalPending = 0; + let totalOverdue = 0; + let totalPaidThisMonth = 0; + + for (const r of records.value) { + countByStatus[r.status] = (countByStatus[r.status] ?? 0) + 1; + if (r.status === 'pending') totalPending += r.final_amount ?? r.amount ?? 0; + if (r.status === 'overdue') totalOverdue += r.final_amount ?? r.amount ?? 0; + if (r.status === 'paid' && r.paid_at) { + const d = new Date(r.paid_at); + if (d.getFullYear() === thisYear && d.getMonth() === thisMonth) { + totalPaidThisMonth += r.final_amount ?? r.amount ?? 0; + } + } + } + return { totalPending, totalOverdue, totalPaidThisMonth, countByStatus }; + }); + + async function fetchRecords(filters = {}) { + loading.value = true; + error.value = ''; + try { + const result = await repoList(filters); + records.value = result.rows; + total.value = result.total; + return result; + } catch (e) { + error.value = e?.message || 'Erro ao carregar registros financeiros.'; + records.value = []; + total.value = 0; + return { rows: [], total: 0 }; + } finally { + loading.value = false; + } + } + + async function fetchByEvent(eventId) { + try { + return await listByEvent(eventId); + } catch (e) { + error.value = e?.message || 'Erro ao buscar records do evento.'; + return []; + } + } + + async function fetchById(recordId) { + try { + return await getById(recordId); + } catch (e) { + error.value = e?.message || 'Erro ao buscar record.'; + return null; + } + } + + async function createRecord(payload) { + loading.value = true; + error.value = ''; + try { + const data = await createForSession(payload); + await fetchRecords(); + return { ok: true, data }; + } catch (e) { + error.value = e?.message || 'Erro ao criar cobrança.'; + return { ok: false, error: e?.message }; + } finally { + loading.value = false; + } + } + + async function createManualRecord(payload) { + loading.value = true; + error.value = ''; + try { + const data = await createManual(payload); + records.value = [data, ...records.value]; + return { ok: true, data }; + } catch (e) { + error.value = e?.message || 'Erro ao criar lançamento manual.'; + return { ok: false, error: e?.message }; + } finally { + loading.value = false; + } + } + + async function markAsPaid(recordId, paymentMethod) { + error.value = ''; + try { + await repoMarkAsPaid(recordId, paymentMethod); + const idx = records.value.findIndex((r) => r.id === recordId); + if (idx !== -1) { + records.value[idx] = { + ...records.value[idx], + status: 'paid', + paid_at: new Date().toISOString(), + payment_method: paymentMethod + }; + } + return { ok: true }; + } catch (e) { + error.value = e?.message || 'Erro ao marcar como pago.'; + return { ok: false, error: e?.message }; + } + } + + async function markAsUnpaid(recordId) { + error.value = ''; + try { + await repoMarkAsUnpaid(recordId); + const idx = records.value.findIndex((r) => r.id === recordId); + if (idx !== -1) { + records.value[idx] = { + ...records.value[idx], + status: 'pending', + paid_at: null, + payment_method: null + }; + } + return { ok: true }; + } catch (e) { + error.value = e?.message || 'Erro ao reverter pagamento.'; + return { ok: false, error: e?.message }; + } + } + + async function cancelRecord(recordId, opts = {}) { + error.value = ''; + try { + await repoCancel(recordId, opts); + const idx = records.value.findIndex((r) => r.id === recordId); + if (idx !== -1) { + records.value[idx] = { ...records.value[idx], status: 'cancelled' }; + } + return { ok: true }; + } catch (e) { + error.value = e?.message || 'Erro ao cancelar registro.'; + return { ok: false, error: e?.message }; + } + } + + async function updateRecord(recordId, patch) { + loading.value = true; + error.value = ''; + try { + const updated = await repoUpdate(recordId, patch); + const idx = records.value.findIndex((r) => r.id === recordId); + if (idx >= 0) records.value[idx] = updated; + return { ok: true, data: updated }; + } catch (e) { + error.value = e?.message || 'Erro ao atualizar registro.'; + return { ok: false, error: e?.message }; + } finally { + loading.value = false; + } + } + + return { + records, + total, + loading, + error, + summary, + fetchRecords, + fetchByEvent, + fetchById, + createRecord, + createManualRecord, + markAsPaid, + markAsUnpaid, + cancelRecord, + updateRecord + }; +} diff --git a/src/features/financeiro/services/_tenantGuards.js b/src/features/financeiro/services/_tenantGuards.js new file mode 100644 index 0000000..13f0414 --- /dev/null +++ b/src/features/financeiro/services/_tenantGuards.js @@ -0,0 +1,24 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/services/_tenantGuards.js +| +| Guards compartilhados pelos repositories do feature financeiro. +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; + +export function assertTenantId(tenantId) { + if (!tenantId || tenantId === 'null' || tenantId === 'undefined') { + throw new Error('Tenant ativo inválido. Selecione a clínica antes de operar no financeiro.'); + } +} + +export 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; +} diff --git a/src/features/financeiro/services/billingContractsRepository.js b/src/features/financeiro/services/billingContractsRepository.js new file mode 100644 index 0000000..4510f7b --- /dev/null +++ b/src/features/financeiro/services/billingContractsRepository.js @@ -0,0 +1,137 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/services/billingContractsRepository.js +| +| Repository de billing_contracts — pacotes/contratos de cobrança (upfront, +| pagamento por sessão, etc). +| +| Gotcha conhecido (memória project_billing_contracts_no_updated_at): +| billing_contracts NÃO tem coluna updated_at. UPDATE com updated_at falha +| silently em Promise.allSettled. Repository NÃO inclui updated_at em patches. +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid } from './_tenantGuards'; +import { BILLING_CONTRACT_SELECT } from './financialSelects'; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; + assertTenantId(tenantId); + return tenantId; +} + +/** + * Lista contratos ativos do paciente. + */ +export async function listForPatient(patientId, { tenantId, includeDeleted = false } = {}) { + if (!patientId) return []; + const tid = resolveTenantId(tenantId); + + let q = supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false }); + + if (!includeDeleted) q = q.is('deleted_at', null); + + const { data, error } = await q; + if (error) throw error; + return data || []; +} + +/** + * Lê contrato por id. Refresh FRESH do banco — usado pelo orchestrator antes + * de UPDATE pra evitar race condition (memória project_agenda_reverse_transitions). + */ +export async function getById(contractId, { tenantId } = {}) { + if (!contractId) throw new Error('contractId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).eq('tenant_id', tid).maybeSingle(); + + if (error) throw error; + return data || null; +} + +/** + * Cria contrato. + */ +export async function create(payload) { + if (!payload?.patient_id) throw new Error('patient_id obrigatório.'); + if (!payload?.charging_style) throw new Error('charging_style obrigatório.'); + + const uid = await getUid(); + const tid = resolveTenantId(payload.tenantId); + + const row = { + tenant_id: tid, + owner_id: uid, + patient_id: payload.patient_id, + charging_style: payload.charging_style, + sessions_total: payload.sessions_total ?? null, + sessions_used: 0, + total_amount: payload.total_amount != null ? Number(payload.total_amount) : null, + status: payload.status || 'active', + start_date: payload.start_date || null, + end_date: payload.end_date || null + }; + + const { data, error } = await supabase.from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Update — NÃO INCLUI updated_at (tabela não tem essa coluna — gotcha conhecido). + */ +export async function update(contractId, patch, { tenantId } = {}) { + if (!contractId) throw new Error('contractId obrigatório.'); + const tid = resolveTenantId(tenantId); + + // eslint-disable-next-line no-unused-vars + const { updated_at: _dropped, ...safePatch } = patch || {}; + + const { data, error } = await supabase.from('billing_contracts').update(safePatch).eq('id', contractId).eq('tenant_id', tid).select(BILLING_CONTRACT_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Incrementa sessions_used em 1 (consume). + * Lê FRESH antes do UPDATE pra evitar race. + */ +export async function incrementSessionsUsed(contractId, { tenantId } = {}) { + const current = await getById(contractId, { tenantId }); + if (!current) throw new Error('Contrato não encontrado.'); + const newCount = (Number(current.sessions_used) || 0) + 1; + return update(contractId, { sessions_used: newCount }, { tenantId }); +} + +/** + * Decrementa sessions_used (reverse). Lê FRESH antes. + */ +export async function decrementSessionsUsed(contractId, { tenantId } = {}) { + const current = await getById(contractId, { tenantId }); + if (!current) throw new Error('Contrato não encontrado.'); + const newCount = Math.max(0, (Number(current.sessions_used) || 0) - 1); + return update(contractId, { sessions_used: newCount }, { tenantId }); +} + +/** + * Busca records cross-week por recurrence_id (memória project_cross_week_propagation). + * Útil pra bulk-load de pacote upfront. + */ +export async function findRecordsByRecurrence(recurrenceId, { tenantId } = {}) { + if (!recurrenceId) return []; + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').eq('tenant_id', tid).is('deleted_at', null).not('agenda_evento_id', 'is', null); + // NOTE: filter por recurrence_id requer join — fica como TODO no orchestrator + // (memória project_cross_week_propagation: query records cross-week por recurrence_id). + + if (error) throw error; + return data || []; +} diff --git a/src/features/financeiro/services/financialExceptionsRepository.js b/src/features/financeiro/services/financialExceptionsRepository.js new file mode 100644 index 0000000..9d41171 --- /dev/null +++ b/src/features/financeiro/services/financialExceptionsRepository.js @@ -0,0 +1,108 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/services/financialExceptionsRepository.js +| +| Regras de exceção financeira (no-show, cancelamento, etc). +| Extraído de src/composables/useAgendaFinanceiro.js. +| +| Prioridade: regra própria do owner > regra global do tenant (owner_id IS NULL). +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid } from './_tenantGuards'; +import { FINANCIAL_EXCEPTION_SELECT } from './financialSelects'; + +const VALID_EXCEPTION_TYPES = ['patient_no_show', 'patient_cancellation', 'professional_cancellation']; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; + assertTenantId(tenantId); + return tenantId; +} + +/** + * Lê a regra de exceção ativa pra um tipo + tenant. + * Prioriza owner próprio se existir; senão regra global do tenant. + */ +export async function getRule(exceptionType, { tenantId } = {}) { + if (!VALID_EXCEPTION_TYPES.includes(exceptionType)) { + throw new Error(`exception_type inválido. Aceitos: ${VALID_EXCEPTION_TYPES.join(', ')}.`); + } + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + + const { data, error } = await supabase + .from('financial_exceptions') + .select(FINANCIAL_EXCEPTION_SELECT) + .eq('tenant_id', tid) + .eq('exception_type', exceptionType) + .or(`owner_id.eq.${uid},owner_id.is.null`) + .order('owner_id', { ascending: false, nullsLast: true }) + .limit(1) + .maybeSingle(); + + if (error) throw error; + return data || null; +} + +/** + * Lista todas as regras do tenant (config page). + */ +export async function listAll({ tenantId } = {}) { + const tid = resolveTenantId(tenantId); + const { data, error } = await supabase.from('financial_exceptions').select(FINANCIAL_EXCEPTION_SELECT).eq('tenant_id', tid).order('exception_type', { ascending: true }); + if (error) throw error; + return data || []; +} + +/** + * Cria/atualiza regra (upsert). + */ +export async function upsertRule(payload) { + if (!payload?.exception_type) throw new Error('exception_type obrigatório.'); + if (!VALID_EXCEPTION_TYPES.includes(payload.exception_type)) { + throw new Error(`exception_type inválido. Aceitos: ${VALID_EXCEPTION_TYPES.join(', ')}.`); + } + + const uid = await getUid(); + const tid = resolveTenantId(payload.tenantId); + + const row = { + tenant_id: tid, + owner_id: payload.ownerScoped ? uid : null, + exception_type: payload.exception_type, + charge_mode: payload.charge_mode || 'none', + charge_value: payload.charge_value != null ? Number(payload.charge_value) : null, + charge_pct: payload.charge_pct != null ? Number(payload.charge_pct) : null, + min_hours_notice: payload.min_hours_notice != null ? Number(payload.min_hours_notice) : null, + default_consume_on_miss: !!payload.default_consume_on_miss, + updated_at: new Date().toISOString() + }; + + const { data, error } = await supabase.from('financial_exceptions').upsert(row, { onConflict: 'tenant_id,owner_id,exception_type' }).select(FINANCIAL_EXCEPTION_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Calcula valor a cobrar conforme charge_mode. + * - none: 0 (não cobra) + * - full: amount original + * - fixed_fee: charge_value fixo + * - percentage: amount * (charge_pct / 100) + */ +export function calcChargeAmount(originalAmount, rule) { + if (!rule || rule.charge_mode === 'none') return 0; + if (rule.charge_mode === 'full') return Number(originalAmount) || 0; + if (rule.charge_mode === 'fixed_fee') return Number(rule.charge_value ?? 0); + if (rule.charge_mode === 'percentage') { + const pct = Number(rule.charge_pct ?? 0); + return parseFloat(((Number(originalAmount) * pct) / 100).toFixed(2)); + } + return Number(originalAmount) || 0; +} diff --git a/src/features/financeiro/services/financialRecordsRepository.js b/src/features/financeiro/services/financialRecordsRepository.js new file mode 100644 index 0000000..d0a4dc9 --- /dev/null +++ b/src/features/financeiro/services/financialRecordsRepository.js @@ -0,0 +1,230 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/services/financialRecordsRepository.js +| +| Repository de financial_records. Extraído de src/composables/useFinancialRecords.js. +| Pattern canônico — ver blueprints/repository-blueprint.md. +| +| Cobre: list (com filtros), getById, createForSession (RPC), createManual, +| markAsPaid (RPC), markAsUnpaid, cancel, update. +| +| RPC `create_financial_record_for_session` (existe no banco) é o caminho +| ÚNICO de criação a partir de sessão — idempotente, ignora cancelled +| (memória project_rpc_idempotency_cancelled). +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid } from './_tenantGuards'; +import { FINANCIAL_RECORD_SELECT, flattenFinancialRecord } from './financialSelects'; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; + assertTenantId(tenantId); + return tenantId; +} + +/** + * Lista lançamentos com filtros + paginação. + * + * @param {Object} [filters] + * @param {string} [filters.tenantId] + * @param {string} [filters.status] - 'pending'|'paid'|'overdue'|'partial'|'cancelled'|'refunded' + * @param {string} [filters.type] - 'receita'|'despesa' + * @param {string} [filters.patient_id] + * @param {string} [filters.due_date_from] + * @param {string} [filters.due_date_to] + * @param {number} [filters.limit=50] + * @param {number} [filters.offset=0] + * @returns {Promise<{rows: Array, total: number}>} + */ +export async function list(filters = {}) { + const tid = resolveTenantId(filters.tenantId); + const limit = filters.limit ?? 50; + const offset = filters.offset ?? 0; + + let q = supabase + .from('financial_records') + .select(FINANCIAL_RECORD_SELECT, { count: 'exact' }) + .eq('tenant_id', tid) + .is('deleted_at', null) + .order('due_date', { ascending: false }) + .range(offset, offset + limit - 1); + + if (filters.status) q = q.eq('status', filters.status); + if (filters.type) q = q.eq('type', filters.type); + if (filters.patient_id) q = q.eq('patient_id', filters.patient_id); + if (filters.due_date_from) q = q.gte('due_date', filters.due_date_from); + if (filters.due_date_to) q = q.lte('due_date', filters.due_date_to); + + const { data, error, count } = await q; + if (error) throw error; + return { + rows: (data || []).map(flattenFinancialRecord), + total: count ?? 0 + }; +} + +/** + * Lê um record por id. + */ +export async function getById(recordId, { tenantId } = {}) { + if (!recordId) throw new Error('recordId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('id', recordId).eq('tenant_id', tid).maybeSingle(); + + if (error) throw error; + return data ? flattenFinancialRecord(data) : null; +} + +/** + * Busca records ativos vinculados a um evento da agenda (status pending|overdue|paid). + * Usado pelo orchestrator pra checar idempotência antes de criar. + */ +export async function listByEvent(eventId, { tenantId } = {}) { + if (!eventId) return []; + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('tenant_id', tid).eq('agenda_evento_id', eventId).is('deleted_at', null); + + if (error) throw error; + return (data || []).map(flattenFinancialRecord); +} + +/** + * Cria cobrança a partir de sessão via RPC idempotente. + * RPC `create_financial_record_for_session` ignora cancelled/refunded — pode + * chamar 2× sem regerar (memória project_rpc_idempotency_cancelled). + */ +export async function createForSession(payload) { + if (!payload) throw new Error('Payload vazio.'); + if (!payload.patient_id) throw new Error('patient_id obrigatório.'); + if (!payload.agenda_evento_id) throw new Error('agenda_evento_id obrigatório.'); + if (payload.amount == null) throw new Error('amount obrigatório.'); + if (!payload.due_date) throw new Error('due_date obrigatório.'); + + const uid = await getUid(); + const tid = resolveTenantId(payload.tenantId); + + const { data, error } = await supabase.rpc('create_financial_record_for_session', { + p_tenant_id: tid, + p_owner_id: uid, + p_patient_id: payload.patient_id, + p_agenda_evento_id: payload.agenda_evento_id, + p_amount: Number(payload.amount), + p_due_date: payload.due_date + }); + + if (error) throw error; + return data; +} + +/** + * Cria lançamento manual avulso (sem sessão). INSERT direto, não via RPC. + */ +export async function createManual(payload) { + if (!payload) throw new Error('Payload vazio.'); + if (payload.amount == null || Number.isNaN(Number(payload.amount))) { + throw new Error('Valor inválido.'); + } + if (!payload.due_date) throw new Error('due_date obrigatório.'); + + const uid = await getUid(); + const tid = resolveTenantId(payload.tenantId); + + const discount = Number(payload.discount_amount ?? 0); + const amount = Number(payload.amount); + + const row = { + tenant_id: tid, + owner_id: uid, + patient_id: payload.patient_id ?? null, + agenda_evento_id: null, + type: payload.type || 'receita', + amount, + discount_amount: discount, + final_amount: amount - discount, + status: payload.status || 'pending', + due_date: payload.due_date, + payment_method: payload.payment_method || null, + description: payload.description ? String(payload.description).trim() || null : null, + notes: payload.notes ? String(payload.notes).trim() || null : null + }; + + const { data, error } = await supabase.from('financial_records').insert([row]).select(FINANCIAL_RECORD_SELECT).single(); + + if (error) throw error; + return flattenFinancialRecord(data); +} + +/** + * Marca record como pago via RPC (server-side timestamps + audit). + */ +export async function markAsPaid(recordId, paymentMethod) { + if (!recordId) throw new Error('recordId obrigatório.'); + + const { data, error } = await supabase.rpc('mark_as_paid', { + p_financial_record_id: recordId, + p_payment_method: paymentMethod + }); + + if (error) throw error; + return data; +} + +/** + * Reverte status pago → pending (UPDATE direto). Mantém payment_method/paid_at + * limpos pra reconciliação manual. + */ +export async function markAsUnpaid(recordId, { tenantId } = {}) { + if (!recordId) throw new Error('recordId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const { error } = await supabase + .from('financial_records') + .update({ + status: 'pending', + paid_at: null, + payment_method: null, + updated_at: new Date().toISOString() + }) + .eq('id', recordId) + .eq('tenant_id', tid); + + if (error) throw error; +} + +/** + * Cancela record (soft — status='cancelled'). Defesa em profundidade: .eq('tenant_id'). + */ +export async function cancel(recordId, { tenantId, reason } = {}) { + if (!recordId) throw new Error('recordId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const patch = { status: 'cancelled', updated_at: new Date().toISOString() }; + if (reason) patch.notes = String(reason).trim() || null; + + const { error } = await supabase.from('financial_records').update(patch).eq('id', recordId).eq('tenant_id', tid); + + if (error) throw error; +} + +/** + * Atualiza campos arbitrários (use com cautela — não atualiza status/paid_at via aqui). + */ +export async function update(recordId, patch, { tenantId } = {}) { + if (!recordId) throw new Error('recordId obrigatório.'); + if (!patch) throw new Error('Patch vazio.'); + const tid = resolveTenantId(tenantId); + + const safePatch = { ...patch, updated_at: new Date().toISOString() }; + + const { data, error } = await supabase.from('financial_records').update(safePatch).eq('id', recordId).eq('tenant_id', tid).select(FINANCIAL_RECORD_SELECT).single(); + + if (error) throw error; + return flattenFinancialRecord(data); +} diff --git a/src/features/financeiro/services/financialSelects.js b/src/features/financeiro/services/financialSelects.js new file mode 100644 index 0000000..cec6172 --- /dev/null +++ b/src/features/financeiro/services/financialSelects.js @@ -0,0 +1,79 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/financeiro/services/financialSelects.js +| +| SELECTs canônicos do feature financeiro. Extraído de src/composables/ +| useFinancialRecords.js (que tinha BASE_SELECT inline). +|-------------------------------------------------------------------------- +*/ + +/** + * SELECT completo de financial_records com joins de patient + agenda_evento. + * FKs explícitas pra evitar ambiguidade. + */ +export const FINANCIAL_RECORD_SELECT = ` + id, tenant_id, owner_id, patient_id, agenda_evento_id, billing_contract_id, + type, amount, discount_amount, final_amount, + status, due_date, paid_at, payment_method, payment_link, + description, notes, created_at, updated_at, + patients!patient_id ( + id, nome_completo, identification_color + ), + agenda_eventos!agenda_evento_id ( + id, inicio_em, status, tipo + ) +` + .replace(/\s+/g, ' ') + .trim(); + +/** + * SELECT mínimo — list views simples, sem joins. + */ +export const FINANCIAL_RECORD_SELECT_BRIEF = ` + id, type, amount, final_amount, status, due_date, paid_at, + description, payment_method, created_at, agenda_evento_id, billing_contract_id +` + .replace(/\s+/g, ' ') + .trim(); + +/** + * Flatten — UI espera `patient_nome` flat às vezes. + */ +export function flattenFinancialRecord(r) { + if (!r) return r; + const patient = r.patients || null; + const evento = r.agenda_eventos || null; + return { + ...r, + patient_nome: patient?.nome_completo || r.patient_nome || '', + patient_color: patient?.identification_color || r.patient_color || '', + evento_inicio_em: evento?.inicio_em || r.evento_inicio_em || null, + evento_status: evento?.status || r.evento_status || null, + evento_tipo: evento?.tipo || r.evento_tipo || null + }; +} + +/** + * SELECT financial_exceptions — regras de cobrança em casos especiais + * (no-show, cancelamento, etc). + */ +export const FINANCIAL_EXCEPTION_SELECT = ` + id, tenant_id, owner_id, exception_type, charge_mode, charge_value, + charge_pct, min_hours_notice, default_consume_on_miss, + created_at, updated_at +` + .replace(/\s+/g, ' ') + .trim(); + +/** + * SELECT billing_contracts. + */ +export const BILLING_CONTRACT_SELECT = ` + id, tenant_id, owner_id, patient_id, charging_style, + sessions_total, sessions_used, total_amount, status, + start_date, end_date, deleted_at, created_at +` + .replace(/\s+/g, ' ') + .trim();