Files
agenciapsilmno/development/02-auditoria/DESIGN_BILLING_ORCHESTRATOR.md
T
Leonardo f94a4ae97f padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes
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>
2026-05-21 04:19:45 -03:00

481 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
```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)