agenda: link universal pacote + refresh saldo no reverse

Dois bugs descobertos durante C11/C+D:

1) Faltou+multa SEM consumeSaldo nao amarrava billing_contract_id
no agenda_evento (so amarrava se consumeSaldo=true). Resultado:
sessao 27/05 do Andre faltou+multa-sem-consume ficou sem rastro
do contrato no DB. Reverse posterior nao detectaria saldoConsumed.
Fix: bloco 1b) universal — sempre amarra quando forward (realizado/
faltou/cancelado) + tem contract + eventoId. Cobre todos os
combos (multa-sem-consume, multa-com-consume, generatePackageCharge,
consumeSaldo solo).

2) Reverse decrementar saldo as vezes nao persistia. Suspeita: race
com ctx.billingContract.sessions_used stale do _loadStatusChangeContext
quando flows rapidos sequenciais (Realizada+gerar -> Agendada
imediato). Fix: refetch FRESH do billing_contracts.sessions_used
direto do DB ANTES de calcular newUsed. Mais robusto contra qualquer
race condition. Adicionado console.log pra futura debug.

Removida duplicidade do amarra-billing_contract_id no bloco
consumeSaldo (universal cobre).

Backfill Andre Green: 27/05 amarrado, saldo voltou pra 2/4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-20 12:21:04 -03:00
parent 45984e885b
commit 5965b05378
@@ -1529,14 +1529,26 @@ function _buildHandlers(deps) {
}
// 2) Devolver saldo ao pacote (se decidiu)
// Refetch sessions_used FRESH antes de decrementar pra evitar
// race condition com flows que rodaram entre _loadStatusChangeContext
// e este ponto (ex: Realizada+gerar imediatamente seguido de Agendada).
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try {
const newUsed = Math.max(0, (ctx.billingContract.sessions_used ?? 0) - 1);
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const totalSessions = freshContract?.total_sessions ?? 0;
const newUsed = Math.max(0, currentUsed - 1);
const patch = { sessions_used: newUsed };
// Se contrato estava 'completed' (atingiu total) e voltou abaixo, reativa.
if ((ctx.billingContract.sessions_used ?? 0) >= (ctx.billingContract.total_sessions ?? 0)) {
if (currentUsed >= totalSessions) {
patch.status = 'active';
}
console.log('[Fase5/reverse] decrementando saldo:', { from: currentUsed, to: newUsed, contractId: ctx.billingContract.id });
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (dErr) throw dErr;
} catch (e) {
@@ -1564,9 +1576,7 @@ function _buildHandlers(deps) {
// 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim)
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse campo
// causa "column does not exist" silenciosamente em Promise.allSettled.
// Também precisa amarrar billing_contract_id no evento — sem isso, o
// reverse não detecta saldoConsumed depois (bug cascata descoberto
// durante teste C11/B: Falta+Descontar, depois Agendada não devolvia).
// Amarração de billing_contract_id no evento é feita em 1b) universal.
if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push(
supabase
@@ -1576,7 +1586,15 @@ function _buildHandlers(deps) {
})
.eq('id', ctx.billingContract.id)
);
// Amarra evento ao contrato pra rastreabilidade + reverse correto
}
// 1b) Amarra evento ao contrato — universal pra forward em pacote.
// Antes só rodava em consumeSaldo / generatePackageCharge. Faltou+multa
// SEM consume era exceção: evento ficava sem billing_contract_id,
// impedindo o reverse de detectar o vínculo depois. Fix: amarrar
// sempre que há contract envolvido + status forward + eventoId real.
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
tasks.push(
supabase
.from('agenda_eventos')