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:
Leonardo
2026-05-21 04:20:23 -03:00
parent 388e9a4186
commit fbfb95648e
9 changed files with 1146 additions and 0 deletions
@@ -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
};
}