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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user