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