diff --git a/src/features/agenda/services/agendaBilling.service.js b/src/features/agenda/services/agendaBilling.service.js index ab6091a..a486ead 100644 --- a/src/features/agenda/services/agendaBilling.service.js +++ b/src/features/agenda/services/agendaBilling.service.js @@ -293,3 +293,524 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status, return ctx; } + +// ── Mutations (Fase B2 — side effects DB) ───────────────────────────────── + +const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); + +/** + * Aplica as decisões tomadas no dialog de status change (reverse / consume + * saldo / multa / mark paid / cobrança pacote). + * + * Recebe deps explícitas (supabase, toast, ownerId, tenantId) em vez de + * capturar via closure. Toast pode ser null — quando chamado fora de UI + * (ex: background job), erros viram exceções no caller. + * + * Mantém a lógica idêntica à versão inline original em useMelissaAgenda. + * + * @param {object} opts + * @param {object} opts.supabase + * @param {object} [opts.toast] — `{ add: fn }`. Opcional. + * @param {string} opts.eventoId + * @param {object} opts.row + * @param {string} opts.novoStatus + * @param {object} opts.ctx — saída de loadStatusChangeContext + * @param {object} opts.decision + * @param {string} opts.ownerId + * @param {string} opts.tenantId + */ +export async function applyStatusDecisions({ supabase, toast, eventoId, row, novoStatus, ctx, decision, ownerId, tenantId }) { + const uid = ownerId; + const patientId = row?.patient_id ?? row?.paciente_id ?? null; + const tasks = []; + const tx = (entry) => { if (toast?.add) toast.add(entry); }; + + // ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ── + if (novoStatus === 'agendado' && ctx.reverseArtifacts) { + const r = ctx.reverseArtifacts; + // 1) Cancelar records pending/overdue + if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) { + const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id); + if (pendingIds.length > 0) { + try { + const today = new Date().toISOString().slice(0, 10); + const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`; + for (const id of pendingIds) { + const { error: cErr } = await supabase + .from('financial_records') + .update({ + status: 'cancelled', + notes: `[${today}] ${reason}`, + updated_at: new Date().toISOString() + }) + .eq('id', id); + if (cErr) throw cErr; + } + } catch (e) { + console.error('[agendaBilling/reverse] erro cancelando records:', e?.message); + tx({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 }); + } + } + } + + // 2) Devolver saldo + if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) { + 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 totalSessions = freshContract?.total_sessions ?? 0; + const newUsed = Math.max(0, currentUsed - 1); + const patch = { sessions_used: newUsed }; + if (currentUsed >= totalSessions) { + patch.status = 'active'; + } + const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id); + if (dErr) throw dErr; + } catch (e) { + console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message); + tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 }); + } + } + + // 3) Desamarrar billing_contract_id (só se devolveu saldo) + if (decision.reverseRestoreSaldo && r.saldoConsumed) { + try { + await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId); + } catch (e) { + console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message); + } + } + + tx({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 }); + return; + } + + // 1) Consumir saldo + if (decision.consumeSaldo && ctx.billingContract?.id) { + tasks.push( + supabase + .from('billing_contracts') + .update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 }) + .eq('id', ctx.billingContract.id) + ); + } + + // 1b) Amarra evento ao contrato (universal pra forward em pacote) + const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado'; + if (isForwardStatus && ctx.billingContract?.id && eventoId) { + tasks.push( + supabase + .from('agenda_eventos') + .update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }) + .eq('id', eventoId) + ); + } + + // 2) Aplicar multa + 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 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 = { + owner_id: uid, + tenant_id: tenantId, + patient_id: patientId, + agenda_evento_id: eventoId, + amount: decision.fineAmount, + final_amount: decision.fineAmount, + description: fineDesc.trim(), + status: 'pending', + due_date: dueIso, + type: 'receita' + }; + tasks.push( + supabase + .from('financial_records') + .insert(finePayload) + .then(({ error }) => { + if (error) { + console.warn('[agendaBilling] INSERT multa falhou:', error?.message, 'payload:', finePayload); + throw error; + } + }) + ); + } + + // 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord) + 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) + if (decision.markPaid && ctx.pendingRecord?.id) { + tasks.push( + supabase + .from('financial_records') + .update({ + status: 'paid', + paid_at: new Date().toISOString(), + payment_method: decision.paymentMethod || 'pix', + updated_at: new Date().toISOString() + }) + .eq('id', ctx.pendingRecord.id) + ); + } + + // 4-pre) Realizado em pacote saldo + paid pré-existente (C12) + const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id; + if (hasAnticipatedPayment) { + if (tasks.length > 0) { + const results = await Promise.allSettled(tasks); + const failed = results.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + console.warn('[agendaBilling/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; + tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 }); + } catch (e) { + console.error('[agendaBilling/realizada-paid] erro consumindo saldo:', e?.message); + tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 }); + } + return; + } + + // 4) Realizado em pacote saldo: amarra + cria cobrança + incrementa saldo + if (decision.generatePackageCharge && ctx.billingContract?.id) { + const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0)); + const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); + + try { + const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId); + if (linkErr) throw linkErr; + } catch (e) { + console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message); + tx({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 }); + } + + try { + const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', { + p_tenant_id: tenantId, + p_owner_id: uid, + p_patient_id: patientId, + p_agenda_evento_id: eventoId, + p_amount: amount, + p_due_date: dueIso + }); + if (rpcErr) throw rpcErr; + } catch (e) { + console.error('[agendaBilling] erro RPC create_financial_record_for_session:', e?.message); + tx({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 }); + } + + try { + const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1; + const patchContract = { sessions_used: newUsed }; + if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) { + patchContract.status = 'completed'; + } + const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id); + if (incErr) throw incErr; + } catch (e) { + console.error('[agendaBilling] erro incrementando sessions_used:', e?.message); + tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 }); + } + } + + // Roda tudo em paralelo + const results = await Promise.allSettled(tasks); + const failed = results.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + const firstErr = failed[0].reason?.message || 'sem detalhe'; + tx({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 }); + console.error('[agendaBilling] falhas em applyStatusDecisions:', failed.map((f) => f.reason)); + } else if (tasks.length > 0) { + tx({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 }); + } + + // Pós-processamento do record gerado pelo pacote saldo + if (decision.generatePackageCharge && eventoId) { + try { + const { data: newRec } = await supabase + .from('financial_records') + .select('id') + .eq('agenda_evento_id', eventoId) + .order('created_at', { ascending: false }) + .limit(1) + .single(); + if (newRec?.id) { + if (decision.markPaid) { + await supabase + .from('financial_records') + .update({ + status: 'paid', + paid_at: new Date().toISOString(), + payment_method: decision.paymentMethod, + updated_at: new Date().toISOString() + }) + .eq('id', newRec.id); + } else if (decision.paymentMethod === 'link') { + await supabase + .from('financial_records') + .update({ payment_method: 'asaas', updated_at: new Date().toISOString() }) + .eq('id', newRec.id); + } + } + } catch { /* silencioso */ } + } +} + +/** + * Cria billing_contract de pacote (upfront ou saldo). Materializa 1ª + * ocorrência + 1 financial_record (estilo upfront), ou só o contrato + * (estilo saldo). + * + * Retorna { toast: { severity, summary, detail, life } } — caller mostra. + */ +export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) { + const { n, packagePrice } = computeSeriePrice(recorrencia); + try { + const { data: createdContract, error: contractErr } = await supabase + .from('billing_contracts') + .insert({ + owner_id: normalized.owner_id, + tenant_id: tenantId, + patient_id: normalized.paciente_id, + type: 'package', + total_sessions: n, + sessions_used: 0, + package_price: packagePrice, + status: 'active', + charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront' + }) + .select('id') + .single(); + if (contractErr) throw contractErr; + const contractId = createdContract?.id ?? null; + + if (packageStyle === 'saldo') { + return { + toast: { + severity: 'success', + summary: 'Pacote criado (saldo)', + detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`, + life: 3500 + } + }; + } + + const durMin = rule.duration_min || 50; + const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); + const firstISO = rule.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); + + const { data: createdEvent, error: evErr } = await supabase + .from('agenda_eventos') + .insert({ + owner_id: rule.owner_id, + tenant_id: tenantId, + terapeuta_id: rule.therapist_id ?? null, + recurrence_id: rule.id, + recurrence_date: firstISO, + tipo: 'sessao', + status: 'agendado', + titulo: normalized.titulo || 'Sessão', + inicio_em: startDt.toISOString(), + fim_em: endDt.toISOString(), + patient_id: normalized.paciente_id, + determined_commitment_id: normalized.determined_commitment_id ?? null, + modalidade: rule.modalidade || normalized.modalidade || 'presencial', + price: packagePrice, + billing_contract_id: contractId, + visibility_scope: normalized.visibility_scope || 'public' + }) + .select('id') + .single(); + if (evErr) throw evErr; + + const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { + p_tenant_id: tenantId, + p_owner_id: rule.owner_id, + p_patient_id: normalized.paciente_id ?? null, + p_agenda_evento_id: createdEvent.id, + p_amount: packagePrice, + p_due_date: firstISO + }); + if (cobErr) throw cobErr; + + const paidNow = markPaidNow === true && paymentMethod !== 'link'; + const { data: recRow } = await supabase + .from('financial_records') + .select('id') + .eq('agenda_evento_id', createdEvent.id) + .order('created_at', { ascending: false }) + .limit(1) + .single(); + if (recRow?.id) { + const patch = { + updated_at: new Date().toISOString(), + payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod + }; + if (paidNow) { + patch.status = 'paid'; + patch.paid_at = new Date().toISOString(); + } + await supabase.from('financial_records').update(patch).eq('id', recRow.id); + } + + const methodLabel = { + pix: 'PIX', + dinheiro: 'dinheiro', + deposito: 'depósito', + cartao_maquininha: 'cartão (maquininha)' + }[paymentMethod] || null; + return { + toast: { + severity: 'success', + summary: paidNow ? 'Pacote pago' : 'Pacote criado', + detail: paidNow + ? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.` + : `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`, + life: 4000 + } + }; + } catch (e) { + return { + toast: { + severity: 'warn', + summary: 'Pacote não gerado', + detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.', + life: 5000 + } + }; + } +} + +/** + * chargeMode='per_session': materializa todas as N ocorrências + 1 financial_record + * por ocorrência. Falha parcial é tolerada (retorna toast warn). + */ +export async function materializeAndChargePerSession({ supabase, rule, normalized, recorrencia, tenantId }) { + const { n, perSessao } = computeSeriePrice(recorrencia); + try { + const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date)); + const dates = generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n); + + const durMin = rule.duration_min || 50; + const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); + const rows = dates.map((iso) => { + const startDt = new Date(`${iso}T00:00:00`); + startDt.setHours(hh, mm, 0, 0); + const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); + return { + owner_id: rule.owner_id, + tenant_id: tenantId, + terapeuta_id: rule.therapist_id ?? null, + recurrence_id: rule.id, + recurrence_date: iso, + tipo: 'sessao', + status: 'agendado', + titulo: normalized.titulo || 'Sessão', + inicio_em: startDt.toISOString(), + fim_em: endDt.toISOString(), + patient_id: normalized.paciente_id, + determined_commitment_id: normalized.determined_commitment_id ?? null, + modalidade: rule.modalidade || normalized.modalidade || 'presencial', + price: perSessao, + visibility_scope: normalized.visibility_scope || 'public' + }; + }); + + const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em'); + if (evErr) throw evErr; + + let okCount = 0; + let failCount = 0; + for (const ev of createdEvents || []) { + try { + const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); + const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { + p_tenant_id: tenantId, + p_owner_id: rule.owner_id, + p_patient_id: normalized.paciente_id ?? null, + p_agenda_evento_id: ev.id, + p_amount: perSessao, + p_due_date: dueDate + }); + if (cobErr) throw cobErr; + okCount++; + } catch { + failCount++; + } + } + + if (failCount === 0) { + return { + toast: { + severity: 'success', + summary: `${okCount} cobranças geradas`, + detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`, + life: 4000 + } + }; + } + return { + toast: { + severity: 'warn', + summary: 'Cobranças parcialmente geradas', + detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`, + life: 6000 + } + }; + } catch (e) { + return { + toast: { + severity: 'warn', + summary: 'Falha ao materializar série', + detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.', + life: 6000 + } + }; + } +} diff --git a/src/layout/melissa/composables/useMelissaAgenda.js b/src/layout/melissa/composables/useMelissaAgenda.js index b579346..34d608c 100644 --- a/src/layout/melissa/composables/useMelissaAgenda.js +++ b/src/layout/melissa/composables/useMelissaAgenda.js @@ -52,12 +52,15 @@ import { pickDbFields } from '@/features/agenda/utils/dbFields'; import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers'; import { pickColor } from '@/features/agenda/utils/colors'; -// ─── Service de billing (Fase B1: read-only + helpers puros) ──────────────── +// ─── Service de billing (Fase B1 read-only + Fase B2 mutations) ──────────── import { computeSeriePrice as _computeSeriePrice, generateOccurrenceDates as _generateOccurrenceDates, loadStatusChangeContext, - needsStatusConfirmDialog + needsStatusConfirmDialog, + applyStatusDecisions, + createPackageContract as _createPackageContractService, + materializeAndChargePerSession as _materializeAndChargePerSessionService } from '@/features/agenda/services/agendaBilling.service'; function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) { @@ -1293,338 +1296,23 @@ function _buildHandlers(deps) { const _needsConfirmDialog = needsStatusConfirmDialog; // Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse). + // _applyStatusDecisions agora é wrapper fino sobre applyStatusDecisions do + // service (Fase B2). Injeta supabase + toast + ownerId + tenantId do escopo. async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) { - const tenantId = clinicTenantId.value; - const uid = ownerId.value; - const patientId = row.patient_id ?? row.paciente_id ?? null; - const tasks = []; - - // ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ── - // Tratado antes dos blocos forward porque a lógica é distinta — - // cancelar records, devolver saldo, sem multa nova. Status já foi - // atualizado pelo _applyStatusUpdateOnly antes desta função. - if (novoStatus === 'agendado' && ctx.reverseArtifacts) { - const r = ctx.reverseArtifacts; - // 1) Cancelar records pending/overdue (se decidiu) - if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) { - const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id); - if (pendingIds.length > 0) { - try { - const today = new Date().toISOString().slice(0, 10); - const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`; - // Cancela um por um pra capturar erro individual; alternativa - // seria UPDATE em batch com IN, mas notes precisa preservar - // o que tinha antes per-row. Aqui priorizamos clareza. - for (const id of pendingIds) { - const { error: cErr } = await supabase - .from('financial_records') - .update({ - status: 'cancelled', - notes: `[${today}] ${reason}`, - updated_at: new Date().toISOString() - }) - .eq('id', id); - if (cErr) throw cErr; - } - } catch (e) { - console.error('[Fase5/reverse] erro cancelando records:', e?.message); - toast.add({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 }); - } - } - } - - // 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 { 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 (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) { - console.error('[Fase5/reverse] erro decrementando saldo:', e?.message); - toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 }); - } - } - - // 3) Desamarrar billing_contract_id do evento (evento agora está - // agendado, conceitualmente sem vínculo ativo até user reusar). - // Só desamarrar se devolveu saldo — se manteve consumido, - // deixa o vínculo pra rastreabilidade. - if (decision.reverseRestoreSaldo && r.saldoConsumed) { - try { - await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId); - } catch (e) { - console.warn('[Fase5/reverse] erro desamarrando billing_contract_id:', e?.message); - } - } - - toast.add({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 }); - return; // pula blocos forward - } - - // 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. - // Amarração de billing_contract_id no evento é feita em 1b) universal. - if (decision.consumeSaldo && ctx.billingContract?.id) { - tasks.push( - supabase - .from('billing_contracts') - .update({ - sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 - }) - .eq('id', ctx.billingContract.id) - ); - } - - // 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') - .update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }) - .eq('id', eventoId) - ); - } - - // 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) { - 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 = { - owner_id: uid, - tenant_id: tenantId, - patient_id: patientId, - agenda_evento_id: eventoId, - amount: decision.fineAmount, - final_amount: decision.fineAmount, - description: fineDesc.trim(), - status: 'pending', - due_date: dueIso, - type: 'receita' - }; - tasks.push( - supabase - .from('financial_records') - .insert(finePayload) - .then(({ error }) => { - if (error) { - console.warn('[Fase5] INSERT multa falhou:', error?.message, 'payload:', finePayload); - throw error; - } - }) - ); - } - - // 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) - if (decision.markPaid && ctx.pendingRecord?.id) { - tasks.push( - supabase - .from('financial_records') - .update({ - status: 'paid', - paid_at: new Date().toISOString(), - payment_method: decision.paymentMethod || 'pix', - updated_at: new Date().toISOString() - }) - .eq('id', ctx.pendingRecord.id) - ); - } - - // 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 - // — durante teste C11/A o sessions_used não incrementava + agenda_evento - // ficava sem billing_contract_id. Ambos os updates não rodavam mas o - // toast warn não aparecia. Agora cada step tem error explícito. - if (decision.generatePackageCharge && ctx.billingContract?.id) { - const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0)); - const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); - - // 4a) Amarra agenda_evento ao contrato. Pra virtual recém-materializada, - // _applyStatusUpdateOnly criou o evento SEM billing_contract_id — - // precisa update separado aqui. - try { - const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId); - if (linkErr) throw linkErr; - } catch (e) { - console.error('[Fase5] erro amarrando billing_contract_id:', e?.message); - toast.add({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 }); - } - - // 4b) Cria financial_record (RPC tolera idempotência) - try { - const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', { - p_tenant_id: tenantId, - p_owner_id: uid, - p_patient_id: patientId, - p_agenda_evento_id: eventoId, - p_amount: amount, - p_due_date: dueIso - }); - if (rpcErr) throw rpcErr; - } catch (e) { - console.error('[Fase5] erro RPC create_financial_record_for_session:', e?.message); - toast.add({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 }); - } - - // 4c) Incrementa sessions_used + completa contract se atingir total - // ⚠ billing_contracts NÃO tem coluna updated_at — passar esse - // campo causa "column does not exist" silenciosamente em - // Promise.allSettled (era o root cause do saldo não incrementar). - try { - const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1; - const patchContract = { sessions_used: newUsed }; - if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) { - patchContract.status = 'completed'; - } - const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id); - if (incErr) throw incErr; - } catch (e) { - console.error('[Fase5] erro incrementando sessions_used:', e?.message); - toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 }); - } - } - - // Roda tudo em paralelo (falha parcial é tolerável — toast warn) - const results = await Promise.allSettled(tasks); - const failed = results.filter((r) => r.status === 'rejected'); - if (failed.length > 0) { - const firstErr = failed[0].reason?.message || 'sem detalhe'; - toast.add({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 }); - console.error('[Fase5] falhas em _applyStatusDecisions:', failed.map((f) => f.reason)); - } else if (tasks.length > 0) { - toast.add({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 }); - } - - // Pós-processamento do record gerado pelo pacote saldo. Agora o - // decision tem markPaid explícito: - // - markPaid=true → vira paid + payment_method=PIX/dinheiro/etc - // - markPaid=false + paymentMethod='link' → pending + payment_method='asaas' - // - markPaid=false + paymentMethod='pending' → pending sem método (default) - if (decision.generatePackageCharge && eventoId) { - try { - const { data: newRec } = await supabase - .from('financial_records') - .select('id') - .eq('agenda_evento_id', eventoId) - .order('created_at', { ascending: false }) - .limit(1) - .single(); - if (newRec?.id) { - if (decision.markPaid) { - await supabase - .from('financial_records') - .update({ - status: 'paid', - paid_at: new Date().toISOString(), - payment_method: decision.paymentMethod, - updated_at: new Date().toISOString() - }) - .eq('id', newRec.id); - } else if (decision.paymentMethod === 'link') { - await supabase - .from('financial_records') - .update({ payment_method: 'asaas', updated_at: new Date().toISOString() }) - .eq('id', newRec.id); - } - // markPaid=false + paymentMethod='pending' → não faz nada - // (record já criado como pending pelo RPC, sem payment_method) - } - } catch { /* silencioso */ } - } + return applyStatusDecisions({ + supabase, + toast, + eventoId, + row, + novoStatus, + ctx, + decision, + ownerId: ownerId.value, + tenantId: clinicTenantId.value + }); } + // ── onDialogSave / onDialogDelete ── populados na Stage 2 ── const onDialogSave = _buildOnDialogSave(deps); const onDialogDelete = _buildOnDialogDelete(deps); @@ -1797,7 +1485,8 @@ function _buildOnDialogSave(deps) { let chargeInfo = null; if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) { if (recChargeMode === 'package') { - chargeInfo = await _createPackageContract({ + chargeInfo = await _createPackageContractService({ + supabase, rule: createdRule, normalized, recorrencia, @@ -1807,7 +1496,7 @@ function _buildOnDialogSave(deps) { markPaidNow: arg?.markPaidNow === true }); } else if (recChargeMode === 'per_session') { - chargeInfo = await _materializeAndChargePerSession({ rule: createdRule, normalized, recorrencia, tenantId: clinicId }); + chargeInfo = await _materializeAndChargePerSessionService({ supabase, rule: createdRule, normalized, recorrencia, tenantId: clinicId }); } } @@ -2349,245 +2038,6 @@ function _buildOnDialogDelete(deps) { }; } -// ─────────────────────────────────────────────────────────────────────────── -// Helpers de cobrança em série (Opção C1, 2026-05-13) -// ─────────────────────────────────────────────────────────────────────────── - -const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); - -// _computeSeriePrice extraído pra agendaBilling.service (Fase B1) — import no topo. - -// chargeMode='package' — 2 estilos (2026-05-14): -// - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência -// em agenda_eventos + cria 1 financial_record com valor TOTAL do pacote -// (vencimento na data da 1ª sessão). Demais ocorrências continuam virtuais. -// Suporta paymentMethod + markPaidNow — marca record como pago quando true. -// - 'saldo': só cria billing_contract (Cliniko style). Sem financial_record -// imediato — cobranças individuais nascem conforme sessões. -async function _createPackageContract({ rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) { - const { n, packagePrice } = _computeSeriePrice(recorrencia); - try { - // 1) billing_contract — referência do pacote em ambos os estilos. - // charging_style: identifica como o pacote foi cobrado na criação; - // handler de status change usa pra decidir entre "só status" (upfront) - // ou "criar cobrança + consumir saldo" (saldo). - const { data: createdContract, error: contractErr } = await supabase - .from('billing_contracts') - .insert({ - owner_id: normalized.owner_id, - tenant_id: tenantId, - patient_id: normalized.paciente_id, - type: 'package', - total_sessions: n, - sessions_used: 0, - package_price: packagePrice, - status: 'active', - charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront' - }) - .select('id') - .single(); - if (contractErr) throw contractErr; - const contractId = createdContract?.id ?? null; - - // Estilo 'saldo': para aqui — sem cobrança imediata. - if (packageStyle === 'saldo') { - return { - toast: { - severity: 'success', - summary: 'Pacote criado (saldo)', - detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`, - life: 3500 - } - }; - } - - // Estilo 'upfront': materializa 1ª ocorrência + 1 financial_record total. - const durMin = rule.duration_min || 50; - const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); - const firstISO = rule.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); - - const { data: createdEvent, error: evErr } = await supabase - .from('agenda_eventos') - .insert({ - owner_id: rule.owner_id, - tenant_id: tenantId, - terapeuta_id: rule.therapist_id ?? null, - recurrence_id: rule.id, - recurrence_date: firstISO, - tipo: 'sessao', - status: 'agendado', - titulo: normalized.titulo || 'Sessão', - inicio_em: startDt.toISOString(), - fim_em: endDt.toISOString(), - patient_id: normalized.paciente_id, - determined_commitment_id: normalized.determined_commitment_id ?? null, - modalidade: rule.modalidade || normalized.modalidade || 'presencial', - price: packagePrice, - billing_contract_id: contractId, - visibility_scope: normalized.visibility_scope || 'public' - }) - .select('id') - .single(); - if (evErr) throw evErr; - - // 2) financial_record do pacote total via RPC. - const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { - p_tenant_id: tenantId, - p_owner_id: rule.owner_id, - p_patient_id: normalized.paciente_id ?? null, - p_agenda_evento_id: createdEvent.id, - p_amount: packagePrice, - p_due_date: firstISO - }); - if (cobErr) throw cobErr; - - // 3) Pós-RPC: ajusta payment_method (sempre) e status (só se markPaidNow=true). - // 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 paidNow = markPaidNow === true && paymentMethod !== 'link'; - const { data: recRow } = await supabase - .from('financial_records') - .select('id') - .eq('agenda_evento_id', createdEvent.id) - .order('created_at', { ascending: false }) - .limit(1) - .single(); - if (recRow?.id) { - const patch = { - updated_at: new Date().toISOString(), - payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod - }; - if (paidNow) { - patch.status = 'paid'; - patch.paid_at = new Date().toISOString(); - } - await supabase.from('financial_records').update(patch).eq('id', recRow.id); - } - - const methodLabel = { - pix: 'PIX', - dinheiro: 'dinheiro', - deposito: 'depósito', - cartao_maquininha: 'cartão (maquininha)' - }[paymentMethod] || null; - return { - toast: { - severity: 'success', - summary: paidNow ? 'Pacote pago' : 'Pacote criado', - detail: paidNow - ? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.` - : `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`, - life: 4000 - } - }; - } catch (e) { - return { - toast: { - severity: 'warn', - summary: 'Pacote não gerado', - detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.', - life: 5000 - } - }; - } -} - -// chargeMode='per_session': materializa todas as N ocorrências como -// agenda_eventos reais + cria 1 financial_record por ocorrência. -// Respeita recurrence_exceptions (feriado_block / cancel_session) — não -// materializa nessas datas. Falha parcial é tolerada (toast warn). -async function _materializeAndChargePerSession({ rule, normalized, recorrencia, tenantId }) { - const { n, perSessao } = _computeSeriePrice(recorrencia); - try { - // 1) Gerar a lista de datas das N ocorrências respeitando exceptions. - const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date)); - const dates = _generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n); - - // 2) Montar rows pra inserção em agenda_eventos. - const durMin = rule.duration_min || 50; - const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); - const rows = dates.map((iso) => { - const startDt = new Date(`${iso}T00:00:00`); - startDt.setHours(hh, mm, 0, 0); - const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); - return { - owner_id: rule.owner_id, - tenant_id: tenantId, - terapeuta_id: rule.therapist_id ?? null, - recurrence_id: rule.id, - recurrence_date: iso, - tipo: 'sessao', - status: 'agendado', - titulo: normalized.titulo || 'Sessão', - inicio_em: startDt.toISOString(), - fim_em: endDt.toISOString(), - patient_id: normalized.paciente_id, - determined_commitment_id: normalized.determined_commitment_id ?? null, - modalidade: rule.modalidade || normalized.modalidade || 'presencial', - price: perSessao, - visibility_scope: normalized.visibility_scope || 'public' - }; - }); - - // 3) Insert batch dos eventos. - const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em'); - if (evErr) throw evErr; - - // 4) Pra cada evento criado, criar financial_record via RPC. Loop - // sequencial pra simplificar — N=4 é pouco; se virar gargalo, batchify. - let okCount = 0; - let failCount = 0; - for (const ev of createdEvents || []) { - try { - const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); - const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { - p_tenant_id: tenantId, - p_owner_id: rule.owner_id, - p_patient_id: normalized.paciente_id ?? null, - p_agenda_evento_id: ev.id, - p_amount: perSessao, - p_due_date: dueDate - }); - if (cobErr) throw cobErr; - okCount++; - } catch { - failCount++; - } - } - - if (failCount === 0) { - return { - toast: { - severity: 'success', - summary: `${okCount} cobranças geradas`, - detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`, - life: 4000 - } - }; - } - return { - toast: { - severity: 'warn', - summary: 'Cobranças parcialmente geradas', - detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`, - life: 6000 - } - }; - } catch (e) { - return { - toast: { - severity: 'warn', - summary: 'Falha ao materializar série', - detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.', - life: 6000 - } - }; - } -} // _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo. // _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.