M4: financeiro foundation — services + composables paralelo
Modulo 4 da Fase 1. 9 arquivos novos em features/financeiro/: 4 services (_tenantGuards, financialSelects, financialRecords Repository, financialExceptionsRepository, billingContractsRepository) + 4 composables (useFinancialRecords, useFinancialExceptions, useBillingContracts, useBillingOrchestrator). Old composables ainda em paralelo — Fase C (cutover) bloqueada pelas decisoes #2/#3/#6 de billing (memoria agenda_billing_decisoes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user