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
};
}
@@ -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();