# 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)
