agenda: C5+C6 testes OK + atalho Gerar fatura + RPC idempotência fix

DB
- migration 20260519000001: create_financial_record_for_session passa a
  ignorar status='cancelled' na idempotência (era bug — cancelar e tentar
  regerar travava silencioso retornando o cancelado)

Cenário 5 (convênio) — fixes pra save + visualização
- Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id
  e usa insurance_value. payment_method forçado 'convenio' (era 'asaas')
- Popover: ev.price era null em convênio → normalize expõe insurance_value
  e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente
- /financeiro: branch novo pra payment_method='convenio' → pill violeta
  com pi-id-card (antes ficava sem indicador, igual particular)

Cenário 6 (recorrente sem pacote, Maria Magali) — materialização
- chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem
  badge $). Agora materializa a 1ª no fluxo de criação recorrente
- Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository
  dropa esse campo. Corrigido pra 'patient_id' (English DB column)

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant
  ='none' + sessão materializada)
- Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick
  (chama gerarCobrancaManual, fecha popover pra impedir double-click)
- Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel
  agora filtram status='cancelled' (resolve badge $ residual + botão sumido)

Header do popover: info de pacote/série
- "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo
  (computed seriesLabel lê do _raw da rule)

Título do dialog "Sessão do Pacote · Sessão"
- Quando commitment name é "Sessão" (default), drop pra evitar duplicação
- Outros nomes (Avaliação, etc) permanecem com forma completa

Excluir série inteira (popover)
- Novo botão "Excluir série" no popover quando evento pertence a recorrência
- Hard delete: financial_records pendentes → agenda_eventos materializados
  → recurrence_rules (CASCADE leva exceptions + rule_services)
- Bloqueia se algum record tem status='paid' (estornar primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception type=
  'cancel_session' (era visível com status cancelado; doc dizia "some
  da agenda" mas código mantinha. Honra a promessa do diálogo)
- patient_missed / therapist_canceled / holiday_block permanecem visíveis
  como histórico

UX outros
- "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava
  — empty state mandava clicar em botão inexistente)
- InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA
  do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada
  estava selecionado antes
- Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia
  opcional"; gratuito = "sem cobrança". Particular sem hint
- recurrence_exceptions cancel agora usa upsert com onConflict
  (idempotente, não quebra com unique violation em re-cancel)
- goToConveniosConfig removida (dead code após quick-create inline)

CSS
- .aed-row-50 perdeu margin-bottom (user request)
- .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 16:23:42 -03:00
parent e95ed9b585
commit c23d0a574f
10 changed files with 589 additions and 58 deletions
+101 -3
View File
@@ -35,8 +35,10 @@ const emit = defineEmits([
'whatsapp',
'historico',
'delete-sessao', // botão "Excluir sessão" — só pra sessões avulsas (sem recorrência)
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
'ver-lancamentos', // botão "Lançamentos" — abre dialog com financial_records vinculados
'antecipar-pagamento' // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
'antecipar-pagamento', // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
'gerar-cobranca' // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
]);
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
@@ -51,6 +53,27 @@ const canDelete = computed(() => {
return true;
});
// Mostra "Excluir série inteira" apenas pra eventos que pertencem a
// uma regra de recorrência (materializados OU ocorrências virtuais).
const isPartOfSeries = computed(() => {
const e = ev.value;
if (!e) return false;
return !!(e.recurrence_id || e.serie_id);
});
// Label "Pacote · X sessões" / "Série semanal" pro popover quando o
// evento pertence a uma recorrência. Lê do _raw que carrega os campos
// da regra (max_occurrences, frequency_type quando expandido).
const seriesLabel = computed(() => {
if (!isPartOfSeries.value) return null;
const raw = ev.value._raw || {};
const total = raw.max_occurrences || raw.qtd_sessoes || raw.series_total || null;
const idx = raw.occurrence_index || raw.serie_index || null;
if (total && idx) return `Sessão ${idx} de ${total}`;
if (total) return `Pacote · ${total} sessões`;
return 'Série recorrente';
});
const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => {
@@ -105,7 +128,9 @@ const paymentIcon = computed(() => {
});
const paymentLabel = computed(() => {
const state = ev.value.paymentState;
const valor = ev.value.price;
// Em sessão particular, valor mora em price. Em convênio, vai pra
// insurance_value (price = null). Fallback cobre os dois casos.
const valor = ev.value.price ?? ev.value.insurance_value;
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
? Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
: null;
@@ -148,7 +173,10 @@ function modalidadeIcon(mod) {
<div class="evento-head__main">
<div class="evento-pill" :style="{ backgroundColor: ev.color }" />
<div class="min-w-0">
<div class="evento-tipo">{{ tipoLabel }}</div>
<div class="evento-tipo">
{{ tipoLabel }}
<span v-if="seriesLabel" class="evento-tipo__series">· {{ seriesLabel }}</span>
</div>
<div class="evento-titulo">
{{ isSessaoComPaciente ? ev.pacienteNome : (ev.label || ev.titulo || '—') }}
</div>
@@ -193,12 +221,38 @@ function modalidadeIcon(mod) {
<i class="pi pi-trash" />
<span>Excluir sessão</span>
</button>
<button
v-if="isPartOfSeries"
type="button"
class="evento-row__edit evento-row__edit--danger"
:disabled="busy"
v-tooltip.top="'Excluir a série inteira (todas as sessões da recorrência)'"
@click="emit('delete-series')"
>
<i class="pi pi-history" />
<span>Excluir série</span>
</button>
</div>
</div>
<div v-if="showPaymentRow" class="evento-row evento-row--pay" :class="`evento-row--pay-${paymentVariant}`">
<i :class="paymentIcon" />
<span>{{ paymentLabel }}</span>
<!-- Atalho "Gerar fatura" pra sessão materializada
com paymentState='none' (cobrança ainda não gerada).
Pago/pendente existe um record; nesses casos não
cabe gerar de novo. -->
<button
v-if="paymentVariant === 'none' && !ev.is_occurrence"
type="button"
class="evento-row__pay-action"
:disabled="busy"
v-tooltip.top="'Gerar fatura agora'"
@click="emit('gerar-cobranca')"
>
<i class="pi pi-plus" />
<span>Gerar fatura</span>
</button>
</div>
<div v-if="ev.modalidade" class="evento-row">
@@ -415,6 +469,13 @@ function modalidadeIcon(mod) {
letter-spacing: 0.2em;
font-weight: 600;
}
.evento-tipo__series {
color: var(--p-primary-color);
text-transform: none;
letter-spacing: 0;
font-weight: 500;
margin-left: 4px;
}
.evento-titulo {
color: var(--m-text);
font-size: 1.1rem;
@@ -501,6 +562,43 @@ html.app-dark .evento-row--pay-pending > i,
html.app-dark .evento-row--pay-none > i {
color: #fbbf24;
}
/* Atalho "Gerar fatura" — pill amber pequeno ao lado de "A cobrar R$ X".
Aparece só pra paymentVariant='none' (sem cobrança ainda). Click emite
'gerar-cobranca' pro parent que chama gerarCobrancaManual sem abrir
o AgendaEventDialog. */
.evento-row__pay-action {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in srgb, #f59e0b 16%, transparent);
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
color: #b45309;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: background-color 120ms ease, border-color 120ms ease;
}
.evento-row__pay-action:hover:not(:disabled) {
background: color-mix(in srgb, #f59e0b 26%, transparent);
border-color: color-mix(in srgb, #f59e0b 60%, transparent);
}
.evento-row__pay-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.evento-row__pay-action > i {
font-size: 0.72rem;
}
html.app-dark .evento-row__pay-action {
color: #fbbf24;
background: color-mix(in srgb, #fbbf24 14%, transparent);
border-color: color-mix(in srgb, #fbbf24 30%, transparent);
}
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
Empilhados verticalmente à direita da linha das horas. */
.evento-row__edit-stack {
@@ -688,6 +688,13 @@ onBeforeUnmount(() => {
Ver boleto
</button>
</div>
<!-- Convênio: aguarda fechamento mensal do plano
pill visual pra distinguir de cobrança particular
direto ao paciente. 2026-05-19. -->
<div v-else-if="data.payment_method === 'convenio'" class="mfl-row__pending-convenio">
<i class="pi pi-id-card" />
{{ paymentLabel(data.payment_method) }}
</div>
<Button
label="Receber"
icon="pi pi-check"
@@ -1566,6 +1573,16 @@ onBeforeUnmount(() => {
flex-direction: column;
gap: 2px;
}
/* Pill "Convênio" — visual distinto do Asaas (violeta) pra deixar claro
que essa cobrança aguarda fechamento mensal do plano, não webhook. */
.mfl-row__pending-convenio {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: rgb(124, 58, 237); /* violet-600 */
font-weight: 500;
}
.mfl-row__pending-method {
display: inline-flex;
align-items: center;
+167 -5
View File
@@ -91,6 +91,7 @@ import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaDockPins } from './composables/useMelissaDockPins';
import { supabase } from '@/lib/supabase/client';
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
@@ -613,6 +614,7 @@ const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const { gerarCobrancaManual } = useAgendaFinanceiro();
const conversationDrawerStore = useConversationDrawerStore();
// ───────────────────────────────────────────────────────────────
@@ -957,13 +959,17 @@ async function onDeleteEvento() {
eventoBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const { error } = await supabase.from('recurrence_exceptions').insert({
// upsert pra ser idempotente: se já existe exception
// pra (recurrence_id, original_date) — edição anterior,
// conflito de criação, cancel duplicado — sobrescreve em
// vez de quebrar com unique violation.
const { error } = await supabase.from('recurrence_exceptions').upsert({
recurrence_id: recId,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
});
}, { onConflict: 'recurrence_id,original_date' });
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
M.refetch();
@@ -1032,14 +1038,16 @@ async function onDeleteEvento() {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (origDate) {
const { error: exErr } = await supabase.from('recurrence_exceptions').insert({
// upsert pra ser idempotente: se já existe exception
// pra (recurrence_id, original_date), sobrescreve.
const { error: exErr } = await supabase.from('recurrence_exceptions').upsert({
recurrence_id: ev.recurrence_id || ev.serie_id,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta'
});
if (exErr) console.warn('[Excluir] exception insert falhou:', exErr?.message);
}, { onConflict: 'recurrence_id,original_date' });
if (exErr) console.warn('[Excluir] exception upsert falhou:', exErr?.message);
}
}
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
@@ -1058,6 +1066,158 @@ async function onDeleteEvento() {
});
}
// Excluir SÉRIE INTEIRA — hard delete (escolha do user em 19/05).
// Deleta recurrence_rules (CASCADE leva exceptions + recurrence_rule_services),
// agenda_eventos materializados (linha por linha pra disparar handlers/triggers),
// e financial_records pendentes ligados. Bloqueia se houver QUALQUER record pago
// (precisa estornar pelo Financeiro primeiro).
async function onDeleteSeries() {
const ev = eventoSelecionado.value;
if (!ev || eventoBusy.value) return;
const ruleId = ev.recurrence_id || ev.serie_id;
if (!ruleId) {
toast.add({ severity: 'warn', summary: 'Sem série', detail: 'Este evento não pertence a uma recorrência.', life: 3000 });
return;
}
// Conta eventos materializados + records vinculados pra montar mensagem
// e detectar paid blockers ANTES de confirmar.
let materializedCount = 0;
let recordsPending = 0;
let hasPaid = false;
try {
const { data: evts } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId);
materializedCount = (evts || []).length;
const evtIds = (evts || []).map((e) => e.id);
if (evtIds.length) {
const { data: recs } = await supabase
.from('financial_records')
.select('id, status')
.in('agenda_evento_id', evtIds)
.is('deleted_at', null);
for (const r of recs || []) {
if (r.status === 'paid') hasPaid = true;
else recordsPending++;
}
}
} catch (e) {
console.warn('[onDeleteSeries] contagem falhou:', e?.message);
}
if (hasPaid) {
toast.add({
severity: 'warn',
summary: 'Série com pagamento confirmado',
detail: 'Uma ou mais sessões desta série têm cobrança paga. Estorne primeiro pelo Financeiro antes de excluir a série.',
life: 6000
});
return;
}
const msgParts = [];
msgParts.push(`Esta ação remove **toda a série de recorrência**:`);
if (materializedCount > 0) msgParts.push(`${materializedCount} sessão(ões) já materializada(s)`);
if (recordsPending > 0) msgParts.push(`${recordsPending} cobrança(s) pendente(s)`);
msgParts.push('e a própria regra. As ocorrências futuras param de aparecer na agenda. **A ação não pode ser desfeita.** Confirmar?');
confirm.require({
header: 'Excluir série inteira',
message: msgParts.join(' '),
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, excluir série',
rejectLabel: 'Manter',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
// 1) Apaga financial_records pendentes vinculados a eventos
// materializados desta série
const { data: evts2 } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId);
const evtIds = (evts2 || []).map((e) => e.id);
if (evtIds.length) {
const { error: recErr } = await supabase
.from('financial_records')
.delete()
.in('agenda_evento_id', evtIds);
if (recErr) throw recErr;
}
// 2) Apaga eventos materializados
if (evtIds.length) {
const { error: evErr } = await supabase
.from('agenda_eventos')
.delete()
.in('id', evtIds);
if (evErr) throw evErr;
}
// 3) Apaga a regra (CASCADE: exceptions + rule_services)
const { error: ruleErr } = await supabase
.from('recurrence_rules')
.delete()
.eq('id', ruleId);
if (ruleErr) throw ruleErr;
toast.add({
severity: 'success',
summary: 'Série excluída',
detail: `Regra + ${materializedCount} sessão(ões)${recordsPending > 0 ? ` + ${recordsPending} cobrança(s)` : ''} removida(s).`,
life: 3500
});
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir série.', life: 5000 });
} finally {
eventoBusy.value = false;
}
}
});
}
// Atalho do popover: gera cobrança da sessão atual via gerarCobrancaManual
// sem precisar abrir o AgendaEventDialog. Mesmo RPC do botão "Gerar
// cobrança" do AgendaEventoFinanceiroPanel. Só vale pra sessão real
// (eventoSelecionado.id existe + não é virtual). Apos sucesso refetch
// pra badge $ aparecer (paymentState='pending').
async function onGerarCobrancaQuick() {
const ev = eventoSelecionado.value;
if (!ev || eventoBusy.value) return;
if (!ev.id || ev.is_occurrence) {
toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Esta ocorrência ainda não está materializada.', life: 3500 });
return;
}
eventoBusy.value = true;
try {
// gerarCobrancaManual le evento.price; passamos a row crua via _raw
// que ja inclui price/insurance_value/billing_contract_id.
const eventoRaw = ev._raw || ev;
const result = await gerarCobrancaManual(eventoRaw);
if (!result.ok) throw new Error(result.error);
const valor = eventoRaw.price ?? eventoRaw.insurance_value ?? 0;
toast.add({
severity: 'success',
summary: 'Cobrança gerada',
detail: `R$ ${Number(valor).toFixed(2).replace('.', ',')} agendado para recebimento.`,
life: 3000
});
M.refetch();
refetchEventosHoje();
// Fecha o popover apos sucesso pra impedir click duplicado
// gerando outra fatura. User reabre se quiser ver estado novo.
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar cobrança.', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -2060,6 +2220,8 @@ function onKeydown(e) {
@remarcar="onRemarcar"
@edit-sessao="onEditEvento"
@delete-sessao="onDeleteEvento"
@delete-series="onDeleteSeries"
@gerar-cobranca="onGerarCobrancaQuick"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@edit-paciente="onEditPaciente"
@@ -152,6 +152,10 @@ function normalizeForMelissa(r, paymentStateMap = null) {
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
paymentState,
price: r.price != null ? Number(r.price) : null,
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
// price). Popover e Resumo usam fallback `price ?? insurance_value`
// pra mostrar o valor da cobrança independente do tipo.
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
_raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
};
}
@@ -459,10 +463,14 @@ export function useMelissaAgenda() {
// resolver (default 'none').
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
if (realIds.length) {
// Filtra cancelados: cobrança cancelada não deve manter
// paymentState='pending' (badge $ residual). Tratamos cancelled
// como "sem cobrança ativa" → cai pro default 'none'.
const { data: recs } = await supabase
.from('financial_records')
.select('agenda_evento_id, paid_at')
.in('agenda_evento_id', realIds);
.select('agenda_evento_id, paid_at, status')
.in('agenda_evento_id', realIds)
.neq('status', 'cancelled');
const map = {};
for (const id of realIds) map[id] = 'none';
for (const rec of recs || []) {
@@ -1551,6 +1559,53 @@ function _buildOnDialogSave(deps) {
}
}
// chargeMode='none' (C6 doc): materializa a 1ª ocorrência
// sem criar financial_record. As demais ficam virtuais.
// Honra: "1ª materializada com badge $ a cobrar; outras 3
// virtuais expandidas em runtime, limpas até interação".
// package/per_session ja materializam dentro dos proprios
// helpers; package 'saldo' intencionalmente NÃO materializa
// (doc C8 — modelo Cliniko, todas virtuais ate interacao).
// IMPORTANTE: agendaRepository.createAgendaEvento dropa
// 'paciente_id' (campo legado). USAR 'patient_id' (English),
// que é o nome real da coluna em agenda_eventos.
const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null;
if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) {
try {
const durMin = createdRule.duration_min || 50;
const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const firstISO = createdRule.start_date;
const startDt = new Date(`${firstISO}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
await create({
owner_id: createdRule.owner_id,
tenant_id: clinicId,
terapeuta_id: createdRule.therapist_id ?? null,
recurrence_id: createdRule.id,
recurrence_date: firstISO,
tipo: normalized.tipo || 'sessao',
status: normalized.status || 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: _patientIdForFirst,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: normalized.modalidade ?? 'presencial',
observacoes: normalized.observacoes ?? null,
extra_fields: normalized.extra_fields ?? null,
price: normalized.price ?? null,
insurance_plan_id: normalized.insurance_plan_id ?? null,
insurance_guide_number: normalized.insurance_guide_number ?? null,
insurance_value: normalized.insurance_value ?? null,
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
visibility_scope: normalized.visibility_scope || 'public'
});
} catch (e) {
console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message);
}
}
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
if (chargeInfo?.toast) toast.add(chargeInfo.toast);
@@ -1790,7 +1845,15 @@ function _buildOnDialogSave(deps) {
// status='paid' apenas quando markPaidNow && method !== 'link'.
if (arg?.chargeMode === 'session' && createdEventId) {
try {
const amount = normalized.price ?? 0;
// Convênio: valor vem em insurance_value (não price);
// payment_method fixo em 'convenio' (não asaas/pix/etc) —
// o plano paga a clínica no fechamento mensal, nunca via
// gateway ou no caixa. markPaidNow ignorado pra convênio
// (record sempre nasce pending até a baixa via Financeiro).
const isConvenio = !!normalized.insurance_plan_id;
const amount = isConvenio
? (normalized.insurance_value ?? 0)
: (normalized.price ?? 0);
const dueDate = normalized.inicio_em
? new Date(normalized.inicio_em).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
@@ -1805,12 +1868,16 @@ function _buildOnDialogSave(deps) {
if (cobErr) throw cobErr;
// Pós-RPC: ajusta payment_method (sempre) e status (só se
// markPaidNow=true e método direto).
// method='link' → payment_method='asaas', status=pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const method = arg?.paymentMethod || 'link';
const paidNow = arg?.markPaidNow === true && method !== 'link';
// markPaidNow=true e método direto). Convênio força method
// = 'convenio' e ignora markPaidNow.
// convenio → payment_method='convenio', status=pending
// method='link' → payment_method='asaas', status=pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const method = isConvenio
? 'convenio'
: (arg?.paymentMethod || 'link');
const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link';
try {
const { data: recRow } = await supabase
.from('financial_records')
@@ -1839,14 +1906,15 @@ function _buildOnDialogSave(deps) {
pix: 'PIX',
dinheiro: 'dinheiro',
deposito: 'depósito',
cartao_maquininha: 'cartão (maquininha)'
cartao_maquininha: 'cartão (maquininha)',
convenio: 'convênio'
}[method] || null;
toast.add({
severity: 'success',
summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada',
detail: paidNow
? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.`
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}.`,
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`,
life: 3500
});
} catch (e) {