agenda: pre-C10 fix _applyStatusDecisions cancela pendingRecord

Bug: ao mudar status pra faltou/cancelado com multa configurada
em financial_exceptions, _applyStatusDecisions INSERIA o novo
record da multa MAS deixava o pendingRecord original em pending.
Resultado: cobranca dupla (R$ 200 original + R$ 30 multa = R$ 230).

Fix em useMelissaAgenda.js:1450-1505:
- applyFine agora carrega data da sessao na description ("Multa
  por falta - sessao dd/mm/aa") pro paciente identificar na fatura.
- Novo bloco 2b: cancela ctx.pendingRecord quando faltou/cancelado,
  com nota de auditoria appendada em notes ("[YYYY-MM-DD] Cancelada
  - substituida por multa de no-show" ou similar). Vale tanto pra
  caso com multa quanto sem (status mudado sem aplicar multa).

Fix dormente em useAgendaFinanceiro.js:59 ('fixed' -> 'fixed_fee')
- charge_mode no schema eh 'fixed_fee' mas calcChargeAmount usava
  'fixed' silenciosamente caia no fallback. Path nao exercitado na
  Melissa (usa _applyStatusDecisions, nao handleStatusChange), mas
  iria quebrar se algum dia fosse.

Pre-teste C10: financial_exceptions seedadas no DB para tenant
Bruno Terapeuta / owner Leonardo:
- patient_no_show: fixed_fee R$ 30
- patient_cancellation: full, min_hours_notice=2, default_consume_on_miss=true

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-20 08:27:16 -03:00
parent ec0a24f5c8
commit d6423da9b4
3 changed files with 56 additions and 15 deletions
+21 -12
View File
@@ -1,19 +1,28 @@
# HANDOFF — 2026-05-20 madrugada (C1-C9 ✅, próximo C10) # HANDOFF — 2026-05-20 (C10 pré-teste · code-fix + seeds prontos)
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.** Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de > **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos no **Cenário 10**
> testes manuais dos 13 cenários do doc viva > (status change avulsa). Code-fix pra cancelar pendingRecord aplicado +
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C9 ✅**. > financial_exceptions da Melissa seedadas no DB. **Falta**: rodar os 5
> Próximo: **Cenário 10** (status change avulsa — Joyce/Ana/Sándor: > sub-testes (a/a2/b/c/c2) e validar.
> marcar como realizado/faltou/cancelado e ver consequências no
> financeiro via STATUS_TO_EXCEPTION + financial_exceptions).
> **🟢 WORKING TREE LIMPO** após commit/push do checkpoint pós-C8/C9. > **🟢 PRÉ-TESTE C10 SALVO**. Pós-teste, próximos cenários: C11 (status
> Per-session funcionando (12 events + 12 records gerados em batch). > change pacote saldo) → C12 (antecipar pagamento) → C13 (edit cobrada).
> Financeiro com rowGroup por paciente (expand/collapse). Bubble-up
> @cobranca-atualizada → M.refetch faz o card da agenda atualizar ### Code-fix aplicado em 20/05 (pré-C10)
> badge/borda imediatamente após pagar. - **`useMelissaAgenda.js:1450-1505`** — `_applyStatusDecisions` agora cancela
o `ctx.pendingRecord` quando faltou/cancelado (com ou sem multa). Antes
inseria a multa mas DEIXAVA o original pending → cobrança dupla
(R$ 200 + R$ 30 = R$ 230). Audit trail vai em `notes` do record
cancelado, descrição da multa nova carrega data: "Multa por falta · sessão 20/05/26".
- **`useAgendaFinanceiro.js:59`** — fix dormente `'fixed'``'fixed_fee'`
(off-by-key contra schema; path nunca exercitado na Melissa, mas iria
quebrar se algum dia fosse).
### Financial exceptions seedadas (tenant Bruno Terapeuta / owner Leonardo)
- `patient_no_show``fixed_fee R$ 30`
- `patient_cancellation``full`, `min_hours_notice=2`, `default_consume_on_miss=true`
--- ---
+1 -1
View File
@@ -56,7 +56,7 @@ const STATUS_TO_EXCEPTION = {
function calcChargeAmount(originalAmount, rule) { function calcChargeAmount(originalAmount, rule) {
if (!rule || rule.charge_mode === 'none') return 0; if (!rule || rule.charge_mode === 'none') return 0;
if (rule.charge_mode === 'full') return originalAmount; if (rule.charge_mode === 'full') return originalAmount;
if (rule.charge_mode === 'fixed') return rule.charge_value ?? 0; if (rule.charge_mode === 'fixed_fee') return rule.charge_value ?? 0;
if (rule.charge_mode === 'percentage') { if (rule.charge_mode === 'percentage') {
const pct = rule.charge_pct ?? 0; const pct = rule.charge_pct ?? 0;
return parseFloat(((originalAmount * pct) / 100).toFixed(2)); return parseFloat(((originalAmount * pct) / 100).toFixed(2));
@@ -1447,9 +1447,12 @@ function _buildHandlers(deps) {
); );
} }
// 2) Aplicar multa (cria financial_record avulsa) // 2) Aplicar multa (cria financial_record avulsa). Description leva
// data da sessão pra paciente identificar na fatura mesmo após cancel.
if (decision.applyFine && decision.fineAmount > 0) { if (decision.applyFine && decision.fineAmount > 0) {
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const sessaoLabel = row.inicio_em ? new Date(row.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
const finePayload = { const finePayload = {
owner_id: uid, owner_id: uid,
tenant_id: tenantId, tenant_id: tenantId,
@@ -1457,7 +1460,7 @@ function _buildHandlers(deps) {
agenda_evento_id: eventoId, agenda_evento_id: eventoId,
amount: decision.fineAmount, amount: decision.fineAmount,
final_amount: decision.fineAmount, final_amount: decision.fineAmount,
description: novoStatus === 'faltou' ? 'Multa por falta (no-show)' : 'Taxa de cancelamento', description: fineDesc.trim(),
status: 'pending', status: 'pending',
due_date: dueIso, due_date: dueIso,
type: 'receita' type: 'receita'
@@ -1475,6 +1478,35 @@ function _buildHandlers(deps) {
); );
} }
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord).
// A sessão não aconteceu/foi cancelada → original substituída pela
// multa (se aplicada) ou simplesmente cancelada. Sem isso cobrava
// dobrado: original R$200 pending + multa R$30 = R$230. Audit trail
// preserva original em notes.
const isFaltouOuCancelado = novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isFaltouOuCancelado && ctx.pendingRecord?.id) {
const reasonText = decision.applyFine
? novoStatus === 'faltou'
? 'Cancelada — substituída por multa de no-show'
: 'Cancelada — substituída por taxa de cancelamento tardio'
: novoStatus === 'faltou'
? 'Cancelada — sessão não realizada (paciente faltou)'
: 'Cancelada — sessão cancelada';
const today = new Date().toISOString().slice(0, 10);
const noteEntry = `[${today}] ${reasonText}`;
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
tasks.push(
supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: noteText,
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status) // 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) { if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push( tasks.push(