Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent Dialog.vue.bak deletado (lixo de refator anterior). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 KiB
Design — useBillingOrchestrator
Data: 2026-05-20 Tipo: Design doc (sem código). Implementação fica pra Módulo 4 (Financeiro) da Fase 1. Resolve: decisão 7 do PADRONIZACAO.md — overlap billing agenda ↔ financeiro com risco de double-billing.
1. Problema atual
1.1 Três caminhos pra criar cobrança
Cobrança de sessão hoje pode ser criada por 3 lugares diferentes:
| # | Caminho | Arquivo | Quando |
|---|---|---|---|
| A | Botão "Gerar cobrança" manual | useAgendaFinanceiro.gerarCobrancaManual() (linha 114) |
User clica explicitamente em sessão sem cobrança |
| B | Mudança de status na agenda | useAgendaFinanceiro.handleStatusChange() (linha 163) |
User troca status (agendado→faltou, etc) |
| C | Decisões aplicadas no Melissa | useMelissaAgenda._applyStatusDecisions() (linha 1450) |
User confirma transição de status no fluxo Melissa |
Os 3 chamam a mesma RPC create_financial_record_for_session. Sem coordenação central. Resultado: race condition silenciosa possível.
1.2 UPDATEs diretos espalhados
handleStatusChange também faz UPDATE/SELECT em financial_records direto (linhas 191, 194, 205, 208) — queries que pertencem ao useFinancialRecords mas são duplicadas aqui pra evitar import circular.
1.3 State em variável de módulo (vaza)
useAgendaFinanceiro.js:38:
const _exceptionsCache = new Map(); // ← módulo-level, vaza entre instâncias
Quando user troca de tenant, cache não invalida automaticamente. Memória useAgendaFinanceiro.invalidateExceptionsCache() precisa ser chamada manualmente em vários lugares.
1.4 Cenários de double-billing concretos
- Race manual + status: user clica "Gerar cobrança" + muda status pra "faltou" em < 200ms. Path A insere registro pending; Path B detecta sessão sem
billed(já que ainda não chegou) e cria outro registro pela exceção. - Realizado vindo de faltou paid: sessão estava
faltoucom multa paid. User volta praagendado→realizado. Path B/C podem regerar cobrança em cima da multa paid existente (memóriaproject_rpc_idempotency_cancelledfoi um fix relacionado mas não cobre todo o problema). - Pacote saldo + adicional: sessão de pacote
billing_contract_idsetado bloqueia Path A (linha 116). Mas Path B/C podem não checar esse campo em alguma branch — risco de cobrança individual em sessão de pacote.
2. Goals & Non-goals
Goals
- Single entry point pra qualquer mudança de billing relacionada a evento da agenda.
- Idempotência garantida — chamar 2× a mesma intenção produz o mesmo resultado.
- State machine explícito de transições de status com consequências financeiras claras.
- Reverse transitions tratadas (realizado→agendado, faltou→agendado, cancelado→agendado).
- Orchestrador NUNCA toca supabase direto — só via repository e composable de financeiro.
- Cache de regras de exceção vive na instância do composable, não em módulo.
Non-goals (fora deste escopo)
- Implementação — só design. Código vem na Fase 1 Módulo 4.
- Refator de
useFinancialRecordsem si (extrair pra repository) — vai junto no Módulo 4. - Gateway de pagamento (Asaas) — Fase 3 do ROADMAP.
- Repasse a terapeutas —
therapist_payoutsseparado. - UI/UX de confirmação de reverse transitions — já mapeado em memória
project_agenda_reverse_transitions, implementação no Módulo 4.
3. State machine de transições
3.1 Status válidos
Enum status_evento_agenda (do schema): agendado | realizado | faltou | cancelado | remarcar
3.2 Matriz from → to
| →agendado | →realizado | →faltou | →cancelado | →remarcar | |
|---|---|---|---|---|---|
| agendado→ | — | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD |
| realizado→ | ⚠️ REVERSE | — | ⚠️ REVERSE | ⚠️ REVERSE | ❌ inválida |
| faltou→ | ⚠️ REVERSE | ⚠️ CROSS | — | ⚠️ CROSS | ❌ inválida |
| cancelado→ | ⚠️ REVERSE | ⚠️ CROSS | ⚠️ CROSS | — | ❌ inválida |
| remarcar→ | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | — |
3.3 Tabela de consequências financeiras
| Transição | Ação financeira default | Decisão do user (override) |
|---|---|---|
agendado→realizado |
Criar pending (se ainda não billed) com amount = event.price |
Marcar como já recebido (forma de pagamento) |
agendado→faltou |
Consultar financial_exceptions[patient_no_show] → criar multa OR cancelar existente |
Consumir saldo de pacote (se aplicável) |
agendado→cancelado |
Consultar financial_exceptions[patient_cancellation] + min_hours_notice → criar taxa de cancelamento tardio OR cancelar existente |
— |
realizado→agendado |
REVERSE: se record paid existe → confirm dialog (refund_paid). Se pending → soft-cancel. Se paid+package: refund + devolver saldo |
Reverter manualmente sem auto |
realizado→faltou |
CROSS: reverter realizado + aplicar regra de no-show. Se já paid → manter pago e converter em multa | — |
faltou→agendado |
REVERSE: cancelar multa pending. Se multa paid → confirm dialog (refund) | — |
cancelado→agendado |
REVERSE: cancelar taxa de cancelamento (se houver) | — |
*→remarcar |
Manter cobrança existente, atualizar due_date quando reagendar |
— |
3.4 Pacote (billing_contract_id presente)
Sobre qualquer transição: se event.billing_contract_id não-nulo, não criar nem cancelar financial_records individual. Em vez disso:
agendado→realizado: incrementabilling_contracts.sessions_usedagendado→faltououagendado→canceladocomdefault_consume_on_miss=true: incrementasessions_usedrealizado→agendado: decrementasessions_used(refresh FRESH do DB antes, memóriaproject_agenda_reverse_transitions)
Memória relevante: project_cross_week_propagation — bulk-load tem que rodar mesmo sem reais na view + query records cross-week por recurrence_id.
4. API shape
4.1 Signature do composable
export function useBillingOrchestrator() {
// ─── State ──────────────────────────────────────────────────
const loading = ref(false); // operação async em andamento
const error = ref(''); // string vazia default (canon do composable-blueprint)
// ─── Public actions ─────────────────────────────────────────
/**
* Orquestra mudança de status de um evento + consequências financeiras.
* Single entry point — substitui os 3 caminhos atuais.
*
* @param {Object} params
* @param {Object} params.event - row de agenda_eventos completa
* @param {string} params.fromStatus
* @param {string} params.toStatus
* @param {Object} [params.decisions] - overrides do user (ver decisões pendentes seção 5)
* @returns {Promise<{ok, actions: Array<BillingAction>, error?}>}
*/
async function applyStatusChange(params) { ... }
/**
* Gera cobrança manual pra evento sem cobrança ainda. Idempotente.
* Bloqueia se billing_contract_id presente.
*/
async function generateChargeForEvent(event, options = {}) { ... }
/**
* Lista records financeiros vinculados ao evento.
*/
async function fetchRecordsForEvent(eventId) { ... }
/**
* Cancela TODOS os records pending/overdue de um evento (soft).
* Use APENAS em reverse transitions confirmadas pelo user.
*/
async function cancelRecordsForEvent(eventId, reason) { ... }
/**
* Lê regra de exceção financeira com cache local (instância).
*/
async function getExceptionRule(tenantId, exceptionType) { ... }
function invalidateRules() { ... } // chama em troca de tenant
return {
loading, error,
applyStatusChange, generateChargeForEvent,
fetchRecordsForEvent, cancelRecordsForEvent,
getExceptionRule, invalidateRules
};
}
4.2 Tipos relevantes
/** @typedef {Object} BillingAction
* Resultado de uma operação. Compõe o array `actions` retornado por applyStatusChange.
* @property {string} type - 'created' | 'updated' | 'cancelled' | 'paid' | 'package_consumed' | 'package_returned' | 'noop'
* @property {string} [recordId]
* @property {number} [amount]
* @property {string} [reason]
*/
/** @typedef {Object} BillingDecisions
* Overrides explícitos do user. Quando ausente, orchestrator decide via regras.
* @property {boolean} [consumePackageSession] - faltou/cancelado: consumir saldo de pacote
* @property {'auto'|'always'|'never'} [applyNoShowFee] - aplicar multa em faltou
* @property {'cancel_pending'|'refund_paid'|'manual'} [reverseCleanup] - reverse: como tratar records existentes
*/
4.3 Exemplo de uso (caller)
// Em AgendaEventDialog.vue (ou onde quer que aplique status change)
import { useBillingOrchestrator } from '@/features/financeiro/composables/useBillingOrchestrator';
const billing = useBillingOrchestrator();
async function onStatusChange(novoStatus, decisoes) {
const result = await billing.applyStatusChange({
event: eventoAtual.value,
fromStatus: eventoAtual.value.status,
toStatus: novoStatus,
decisions: decisoes // pode ser undefined — orchestrator usa regras default
});
if (!result.ok) {
toast.add({ severity: 'error', summary: 'Falha', detail: result.error });
return;
}
// result.actions é narrativa do que aconteceu — use pra UI feedback
for (const action of result.actions) {
if (action.type === 'created') showCreatedToast(action.amount);
else if (action.type === 'cancelled') showCancelledToast();
// ...
}
}
5. Arquitetura interna
5.1 Dependências (camadas)
useBillingOrchestrator
│
├──> useFinancialRecords (thin wrapper)
│ │
│ └──> financialRecordsRepository
│ │
│ └──> supabase (RPC + tabela)
│
├──> useBillingContracts (composable a criar — pacotes)
│ │
│ └──> billingContractsRepository
│
├──> useFinancialExceptions (composable — regras de exceção)
│ │
│ └──> financialExceptionsRepository
│
└──> useAgendaEvents (THIN — só pra propagação reativa, não pra writes)
│
└──> agendaRepository (já existe)
Regra absoluta: orchestrator não importa supabase diretamente. Só dos composables/repositories acima.
5.2 Internals
// PRIVATE — não exportado
const _rulesCache = new Map(); // ← agora DENTRO da function, vive com a instância
async function _resolveBillingState(eventId) {
// Snapshot completo: records[], contract?, exceptionRule?
// Pra decidir transição sem race conditions.
const records = await financialRecords.fetchByEvent(eventId);
const packageInfo = event.billing_contract_id
? await billingContracts.fetch(event.billing_contract_id)
: null;
return { records, packageInfo };
}
async function _runTransition(event, fromStatus, toStatus, decisions, state) {
const key = `${fromStatus}→${toStatus}`;
const handler = TRANSITION_HANDLERS[key];
if (!handler) {
throw new Error(`Transição inválida: ${key}`);
}
return handler({ event, decisions, state });
}
const TRANSITION_HANDLERS = {
'agendado→realizado': _handleRealizado,
'agendado→faltou': _handleFaltou,
// ... 1 handler por transição válida
};
async function _handleRealizado({ event, decisions, state }) {
if (event.billing_contract_id) {
return _consumePackageSession(event);
}
// Sessão avulsa — criar pending se não tem record ativo
const hasActive = state.records.some(r => ['pending','overdue','paid'].includes(r.status));
if (hasActive) {
return [{ type: 'noop', reason: 'Record já existe' }];
}
const record = await financialRecords.create({
patient_id: event.patient_id,
agenda_evento_id: event.id,
amount: event.price,
due_date: _eventDateISO(event)
});
return [{ type: 'created', recordId: record.id, amount: event.price }];
}
async function _handleFaltou({ event, decisions, state }) {
if (event.billing_contract_id) {
return decisions?.consumePackageSession ?? state.exceptionRule?.default_consume_on_miss
? _consumePackageSession(event)
: [{ type: 'noop' }];
}
const rule = await getExceptionRule(event.tenant_id, 'patient_no_show');
if (!rule || rule.charge_mode === 'none') {
return _cancelExistingPending(state.records);
}
// ... lógica completa
}
5.3 Idempotência — como o orchestrator garante
Antes de criar record:
// 1. Snapshot da state ANTES da decisão (já feito em _resolveBillingState)
// 2. Verificar se record ativo já existe pro evento+intenção
const existingActive = state.records.find(r =>
['pending', 'overdue', 'paid'].includes(r.status)
);
if (existingActive) {
// Decidir: noop, update, ou criar segundo (raro — multa em cima de sessão paid)
}
// 3. Locks otimistas via UPDATE com .eq('status', expectedStatus)
// Se conflito, refresh + re-decide
Antes de update/cancel:
// Sempre filtra .eq('tenant_id', tid) defesa em profundidade
// (corrige divergências do audit baseline)
Para mudanças de pacote:
// REFRESH FRESH do banco antes do UPDATE (memória project_agenda_reverse_transitions)
const currentContract = await billingContracts.fetch(id);
await billingContracts.update(id, {
sessions_used: currentContract.sessions_used - 1
});
5.4 RPC create_financial_record_for_session — usar como single insert path
A RPC já existe e tem idempotência (memória project_rpc_idempotency_cancelled foi fix recente). Orchestrator usa ela como única forma de criar record de sessão. INSERT direto fica APENAS pra createManualRecord (lançamento avulso sem evento), que continua em useFinancialRecords.createManualRecord.
6. Plano de migração (faseado, Módulo 4)
Fase A — Foundation (preparar terreno)
- Criar
features/financeiro/services/com:_tenantGuards.js(copy do agenda)financialSelects.js(extrairBASE_SELECTatual)financialRecordsRepository.js(extrair queries douseFinancialRecords)financialExceptionsRepository.js(novo — prafinancial_exceptions)billingContractsRepository.js(novo — prabilling_contracts)
- Adicionar
.eq('tenant_id', tid)em todas operações (fix do audit alta sev).
Fase B — Composables refatorados (sem mudar callers)
- Mover
useFinancialRecords.jsprafeatures/financeiro/composables/. - Refatorar pra usar repository (thin wrapper). Aplicar canon
error = ref(''). - Criar
features/financeiro/composables/useFinancialExceptions.js. - Criar
features/financeiro/composables/useBillingContracts.js. - Criar
features/financeiro/composables/useBillingOrchestrator.jscom signature acima. - Callers ainda usam
useFinancialRecordsdireto +useAgendaFinanceiro— nada quebra ainda.
Fase C — Migração de callers (1 por vez)
- Migrar
AgendaEventDialog.vueprauseBillingOrchestrator.applyStatusChangeem vez deuseAgendaFinanceiro.handleStatusChange. - Migrar
useMelissaAgenda._applyStatusDecisions(linha 1450-1505) pra orchestrator. - Migrar todos os callers de
useAgendaFinanceiro.gerarCobrancaManual→useBillingOrchestrator.generateChargeForEvent.
Fase D — Cleanup
- Deletar
src/composables/useAgendaFinanceiro.js(callers todos migrados). - Deletar
src/composables/useFinancialRecords.jsraiz (versão refatorada vive emfeatures/financeiro/composables/). - Remover
_exceptionsCachemódulo-level (já estava no novo composable).
Sanity checks pós-migração
- E2E Playwright: criar sessão → realizar com pagamento → mudar pra faltou → confirmar reverse → verificar contract.sessions_used. NUNCA double-billing.
- Memory
project_agenda_billing_decisoes— confirmar 5 decisões mantidas (#1 híbrido, #4 semi-auto no-show, #5 bloqueia edit cobrada, #7 credit note, #8 pagamento separado).
7. Decisões resolvidas (2026-05-20)
7.1 ✅ applyStatusChange faz APENAS financeiro
Signature final: applyStatusChange({ event, fromStatus, toStatus, decisions }) retorna { ok, actions[], needsConfirmation?, error? }. Caller é responsável por atualizar a agenda separadamente (agendaRepository.update()).
Consequência: orchestrator stateless quanto à agenda. Caller faz wrapping:
await agendaEvents.update(event.id, { status: novoStatus });
const billing = await billingOrchestrator.applyStatusChange({ ... });
// Se billing.needsConfirmation, mostrar dialog. Se erro, considerar rollback do status.
7.2 ✅ Reverse confirm via needsConfirmation no return
Quando orchestrator detecta reverse com record paid (realizado paid → agendado) ou pacote saldo consumido:
// Primeira chamada — sem decisions
const r = await billingOrchestrator.applyStatusChange({ event, fromStatus: 'realizado', toStatus: 'agendado' });
// r = { ok: false, needsConfirmation: true, options: [
// { key: 'cancel_pending', label: 'Cancelar cobrança pendente', amount: 200 },
// { key: 'refund_paid', label: 'Estornar pagamento', amount: 200 },
// { key: 'manual', label: 'Resolver manualmente depois' }
// ] }
// Caller mostra dialog → user escolhe → re-chama
const r2 = await billingOrchestrator.applyStatusChange({
event, fromStatus: 'realizado', toStatus: 'agendado',
decisions: { reverseCleanup: 'refund_paid' }
});
// r2 = { ok: true, actions: [{ type: 'refunded', recordId, amount }] }
7.3 ✅ Transação via RPC apply_billing_status_transition
Toda mudança financeira de transição roda em RPC dedicada. Tudo ou nada. RPC entra como migration durante Módulo 4. Composable orchestrator faz apenas:
- Resolve state atual (snapshot read-only)
- Calcula decisões (state machine no JS)
- Chama RPC com plano completo (
p_actions jsonb) - RPC executa em ordem dentro de uma transação SQL
Signature proposta da RPC:
CREATE FUNCTION public.apply_billing_status_transition(
p_tenant_id uuid,
p_event_id uuid,
p_actions jsonb -- [{ kind: 'create_record', amount, due_date }, { kind: 'cancel_record', record_id }, ...]
) RETURNS jsonb; -- { ok, applied: [...], failed?: { kind, reason } }
7.4 ✅ Decisões #2/#3/#6 de billing — sessão dedicada antes do Módulo 4
Marcar sessão dedicada (~1h) pra fechar memória project_agenda_billing_decisoes antes da implementação. Bloqueador parcial do Módulo 4 — orchestrator pode ser parcialmente implementado, mas a state machine só fica completa após resolver essas 3 decisões.
⚠️ Pendência rastreada: adicionar item em
dev_auditoria_itemsou agenda recorrente. Vai aparecer como TODO inicial na sessão de implementação do Módulo 4.
8. Open questions (não-bloqueantes pro design)
-
patient_timelineintegration: quando orchestrator cria/cancela record, devia emitir eventopagamento_recebido/pagamento_vencidoempatient_timeline? Hoje o enum suporta, mas não vejo inserts. Sugestão: adicionar como trigger no banco (não no orchestrator) — fica resiliente a chamadas que escapam do orchestrator. -
Gateway webhook (Asaas): quando Asaas dispara webhook "paid", quem processa? Edge function dedicada que chama
financialRecords.markAsPaid(id, 'pix'), sem passar por orchestrator (orchestrator é só pra mudanças via agenda). Documentar como caminho separado válido. -
Repasse a terapeuta (
therapist_payouts): quando record ficapaid, gera entrada emtherapist_payout_records? Hoje não. Decisão: trigger no banco que escuta UPDATE emfinancial_records.status→ cria payout. Fora do escopo do orchestrator. -
patient_assessments(Fase 2): notas clínicas com escalas têm relação com billing? Improvável — assessments são clínicos, não-monetizados. Confirmar quando implementar.
9. Cross-references
- Memórias relevantes:
project_rpc_idempotency_cancelled.md— RPCs ignoram cancelledproject_billing_contracts_no_updated_at.md— gotcha de UPDATE silently failingproject_agenda_billing_decisoes.md— 5 decisões baseproject_agenda_reverse_transitions.md— confirm dialogs pra reverterproject_cross_week_propagation.md— pacote upfront cross-weekproject_c12_antecipar_iterar.md— antecipar pacote (Watch sync resolveu snapshot stale)
- Audit baseline divergências Financeiro: ver
AUDIT_BASELINE.mdseção 4 - Blueprints:
repository-blueprint.md+composable-blueprint.md - ROADMAP: Fase 1 itens 1-4 (Monetização)
10. Checklist pra implementação (Módulo 4 da Fase 1)
- 5 repositories criados em
features/financeiro/services/ - 4 composables criados em
features/financeiro/composables/ useBillingOrchestratorcom signature acima- State machine completa (todas transições da matriz 3.2)
- Cache de regras dentro da instância (não módulo-level)
- Idempotência testada (chamada 2× = noop)
.eq('tenant_id', tid)em todas mutações (defesa em profundidade)- RPC
apply_billing_status_transition(decisão 7.3 opção B) - AgendaEventDialog migrado pra orchestrator
- useMelissaAgenda._applyStatusDecisions migrado
- gerarCobrancaManual callers migrados
- useAgendaFinanceiro.js deletado
- useFinancialRecords.js raiz deletado
- E2E test cobrindo reverse transition (realizado paid → agendado)
- Confirm dialogs UI implementados (memória project_agenda_reverse_transitions)