agenda: C12 prep — detectar paid pre-existente em pacote saldo realizada

Preparacao pra teste C12 (antecipar pagamento). Fluxo:
1. User clica "Antecipar pagamento" em virtual futura -> cria
   record paid R$ X sem consumir saldo
2. Depois marca a sessao como Realizada -> dialog deve detectar
   o paid + so consumir saldo (NAO criar record novo, evitar
   duplicidade)

Sem esse fix, marcar Realizada apos antecipar abriria o dialog
"Gerar cobranca?" com default true, gerando record novo duplicado.

Implementacao:
- _loadStatusChangeContext: carrega ctx.existingPaidRecord (qualquer
  paid linkado ao evento, n=1)
- Dialog: nova prop existingPaidRecord + computed showAlreadyPaid
  (substitui showCobrancaPacote quando paid existe)
- Template: bloco "Sessao ja paga via antecipacao" com info do
  pagamento + preview do consumo de saldo
- _applyStatusDecisions: novo branch 4-pre roda ANTES do generatePackageCharge:
  se realizado+pacote saldo+paid existe, roda tasks pendentes (1b
  amarra) + incrementa saldo sem criar record. Return cedo.

Backfill: Andre 10/06 voltou pra agendado + saldo 2/4 (estado limpo
pra testar C12 com a sessao 10/06 antecipando).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-20 12:28:19 -03:00
parent 9ead3fdc42
commit 00c4168393
2 changed files with 85 additions and 2 deletions
@@ -49,6 +49,9 @@ const props = defineProps({
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
pendingRecord: { type: Object, default: null },
// Quando pacote saldo + realizado + record paid pré-existente (C12 antecipado):
// dialog não oferece "Gerar cobrança" — só confirma consumo de saldo.
existingPaidRecord: { type: Object, default: null },
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
sessionPrice: { type: Number, default: 0 },
// Reverse transition (novoStatus='agendado'): artefatos a desfazer.
@@ -135,8 +138,11 @@ const showSaldoBlock = computed(() => isFaltouOrCancelado.value && isPacoteSaldo
// Mostrar bloco "registrar pagamento": realizado + avulsa pendente
const showRegistrarPagto = computed(() => isRealizado.value && isAvulsa.value && props.pendingRecord && props.pendingRecord.status === 'pending');
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value);
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo + SEM paid pré-existente
// (se já tem paid via antecipação, mostra o bloco "já pago" em vez deste)
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value && !props.existingPaidRecord);
// Mostrar bloco "já paga via antecipação": realizado + pacote saldo + paid pré-existente
const showAlreadyPaid = computed(() => isRealizado.value && isPacoteSaldo.value && !!props.existingPaidRecord);
// ─── Header ────────────────────────────────────────────────────────────
const headerTitle = computed(() => {
@@ -443,6 +449,23 @@ function onCancel() {
</div>
</div>
<!-- Bloco "JÁ PAGA via antecipação" (C12 fluxo de retorno) -->
<div v-if="showAlreadyPaid" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-check-circle" />
Sessão paga via antecipação
</div>
<div class="asccd-info">
<i class="pi pi-info-circle" />
<div>
Cobrança de <b>R$ {{ _fmtBRL(existingPaidRecord.final_amount || existingPaidRecord.amount) }}</b>
foi registrada como paga ({{ existingPaidRecord.payment_method || 'método não definido' }}).
Marcar como Realizada vai <b>consumir 1 sessão do saldo</b>
({{ billingContract?.sessions_used ?? 0 }} {{ (billingContract?.sessions_used ?? 0) + 1 }}/{{ billingContract?.total_sessions ?? '?' }}).
</div>
</div>
</div>
<!-- Bloco COBRANÇA NO PACOTE (realizado + pacote saldo) -->
<div v-if="showCobrancaPacote" class="asccd-block">
<div class="asccd-block__title">
@@ -1243,6 +1243,7 @@ function _buildHandlers(deps) {
billingContract: ctx.billingContract,
billingContractStyle: ctx.billingContract?.charging_style ?? null,
pendingRecord: ctx.pendingRecord,
existingPaidRecord: ctx.existingPaidRecord || null,
sessionPrice: Number(row.price ?? 0),
reverseArtifacts: ctx.reverseArtifacts || null
});
@@ -1415,6 +1416,27 @@ function _buildHandlers(deps) {
}
}
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
// Quando user antecipou paga ANTES de marcar Realizada, o record paid
// já existe ao tempo do status change. Dialog precisa saber pra:
// - Não oferecer "Gerar cobrança nova" (geraria duplicidade)
// - Ainda incrementar sessions_used (a sessão consome saldo do pacote)
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.existingPaidRecord = data ?? null;
} catch (e) {
console.warn('[Fase5] erro existing paid record:', e?.message);
}
}
// 4) Reverse transition (status novo='agendado'): carrega artefatos
// a desfazer — current status + ALL records ativos + saldo consumido.
// Sem isso, voltar pra agendado deixa multa/record/saldo órfão.
@@ -1678,6 +1700,44 @@ function _buildHandlers(deps) {
);
}
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12: antecipou)
// Sessão já paga via "Antecipar pagamento" anteriormente. Realizada
// agora não deve gerar record novo (duplicaria cobrança) — só
// amarrar contrato (via tasks 1b) + incrementar saldo. Rodamos os
// tasks pendentes antes do incremento pra não perder o link.
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
if (hasAnticipatedPayment) {
// Roda tasks acumulados (basicamente 1b amarra) antes do incremento
if (tasks.length > 0) {
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
console.warn('[Fase5/realizada-paid] tasks com falha:', failed.map((f) => f.reason?.message));
}
}
try {
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 newUsed = currentUsed + 1;
const patch = { sessions_used: newUsed };
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
patch.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
toast.add({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
} catch (e) {
console.error('[Fase5/realizada-paid] erro consumindo saldo:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
}
return;
}
// 4) Realizado em pacote saldo: amarra contract + cria cobrança + incrementa saldo
// Refatorado pra usar AWAITS SEQUENCIAIS (igual onUsarSessao do MelissaLayout).
// Antes era Promise.allSettled paralelo que escondia falhas silenciosas