# 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`: ```js 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 1. **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. 2. **Realizado vindo de faltou paid:** sessão estava `faltou` com multa paid. User volta pra `agendado` → `realizado`. Path B/C podem regerar cobrança em cima da multa paid existente (memória `project_rpc_idempotency_cancelled` foi um fix relacionado mas não cobre todo o problema). 3. **Pacote saldo + adicional:** sessão de pacote `billing_contract_id` setado 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 1. **Single entry point** pra qualquer mudança de billing relacionada a evento da agenda. 2. **Idempotência garantida** — chamar 2× a mesma intenção produz o mesmo resultado. 3. **State machine explícito** de transições de status com consequências financeiras claras. 4. **Reverse transitions** tratadas (realizado→agendado, faltou→agendado, cancelado→agendado). 5. **Orchestrador NUNCA toca supabase direto** — só via repository e composable de financeiro. 6. **Cache de regras de exceção** vive na instância do composable, não em módulo. ### Non-goals (fora deste escopo) 1. Implementação — só design. Código vem na Fase 1 Módulo 4. 2. Refator de `useFinancialRecords` em si (extrair pra repository) — vai junto no Módulo 4. 3. Gateway de pagamento (Asaas) — Fase 3 do ROADMAP. 4. Repasse a terapeutas — `therapist_payouts` separado. 5. 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`: incrementa `billing_contracts.sessions_used` - `agendado→faltou` ou `agendado→cancelado` com `default_consume_on_miss=true`: incrementa `sessions_used` - `realizado→agendado`: decrementa `sessions_used` (refresh FRESH do DB antes, memória `project_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 ```js 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, 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 ```js /** @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) ```js // 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 ```js // 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: ```js // 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: ```js // Sempre filtra .eq('tenant_id', tid) defesa em profundidade // (corrige divergências do audit baseline) ``` Para mudanças de pacote: ```js // 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) 1. Criar `features/financeiro/services/` com: - `_tenantGuards.js` (copy do agenda) - `financialSelects.js` (extrair `BASE_SELECT` atual) - `financialRecordsRepository.js` (extrair queries do `useFinancialRecords`) - `financialExceptionsRepository.js` (novo — pra `financial_exceptions`) - `billingContractsRepository.js` (novo — pra `billing_contracts`) 2. Adicionar `.eq('tenant_id', tid)` em todas operações (fix do audit alta sev). ### Fase B — Composables refatorados (sem mudar callers) 1. Mover `useFinancialRecords.js` pra `features/financeiro/composables/`. 2. Refatorar pra usar repository (thin wrapper). Aplicar canon `error = ref('')`. 3. Criar `features/financeiro/composables/useFinancialExceptions.js`. 4. Criar `features/financeiro/composables/useBillingContracts.js`. 5. Criar `features/financeiro/composables/useBillingOrchestrator.js` com signature acima. 6. Callers ainda usam `useFinancialRecords` direto + `useAgendaFinanceiro` — **nada quebra ainda**. ### Fase C — Migração de callers (1 por vez) 1. Migrar `AgendaEventDialog.vue` pra `useBillingOrchestrator.applyStatusChange` em vez de `useAgendaFinanceiro.handleStatusChange`. 2. Migrar `useMelissaAgenda._applyStatusDecisions` (linha 1450-1505) pra orchestrator. 3. Migrar todos os callers de `useAgendaFinanceiro.gerarCobrancaManual` → `useBillingOrchestrator.generateChargeForEvent`. ### Fase D — Cleanup 1. Deletar `src/composables/useAgendaFinanceiro.js` (callers todos migrados). 2. Deletar `src/composables/useFinancialRecords.js` raiz (versão refatorada vive em `features/financeiro/composables/`). 3. Remover `_exceptionsCache` mó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: ```js 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: ```js // 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: 1. Resolve state atual (snapshot read-only) 2. Calcula decisões (state machine no JS) 3. Chama RPC com plano completo (`p_actions jsonb`) 4. RPC executa em ordem dentro de uma transação SQL Signature proposta da RPC: ```sql 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_items` ou 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) 1. **`patient_timeline` integration:** quando orchestrator cria/cancela record, devia emitir evento `pagamento_recebido` / `pagamento_vencido` em `patient_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. 2. **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. 3. **Repasse a terapeuta (`therapist_payouts`):** quando record fica `paid`, gera entrada em `therapist_payout_records`? Hoje não. Decisão: trigger no banco que escuta UPDATE em `financial_records.status` → cria payout. Fora do escopo do orchestrator. 4. **`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 cancelled - `project_billing_contracts_no_updated_at.md` — gotcha de UPDATE silently failing - `project_agenda_billing_decisoes.md` — 5 decisões base - `project_agenda_reverse_transitions.md` — confirm dialogs pra reverter - `project_cross_week_propagation.md` — pacote upfront cross-week - `project_c12_antecipar_iterar.md` — antecipar pacote (Watch sync resolveu snapshot stale) - Audit baseline divergências Financeiro: ver `AUDIT_BASELINE.md` seçã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/` - [ ] `useBillingOrchestrator` com 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)