/* |-------------------------------------------------------------------------- | Agência PSI — agendaBilling service (Fase B1) |-------------------------------------------------------------------------- | Helpers e loaders relacionados a billing da agenda, extraídos de | useMelissaAgenda.js pra serem reusados em Rail/Clínica. | | Esta sessão (Fase B1) cobre só read-only + helpers puros: | - computeSeriePrice (puro) | - generateOccurrenceDates (puro) | - loadStatusChangeContext (read-only DB) | - needsStatusConfirmDialog (puro) | | Fase B2 (mutations) extrairá: applyStatusDecisions, createPackageContract, | materializeAndChargePerSession. | | Convenção: funções recebem `supabase` explícito (não usa import direto) | pra facilitar teste + reuso fora do contexto Vue. Nenhuma função aqui | dispara toast — caller decide. |-------------------------------------------------------------------------- */ import { dateToISO } from '@/features/agenda/utils/timeHelpers'; import { tenantDb } from '@/lib/supabase/tenantClient'; // ── Helpers puros ───────────────────────────────────────────────────────── /** * Calcula o valor total da série a partir dos commitmentItems. * * @param {object} recorrencia { qtdSessoes, commitmentItems, serieValorMode } * @returns {{ n, perSessao, packagePrice }} */ export function computeSeriePrice(recorrencia) { const items = recorrencia?.commitmentItems || []; const n = recorrencia?.qtdSessoes || 1; const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0); const pacoteFechado = recorrencia?.serieValorMode === 'dividir'; return { n, perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao, packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n }; } /** * Gera lista de datas ISO ('YYYY-MM-DD') a partir de uma rule de recorrência. * Pula datas em exceptionDates (Set). Para até `max` datas. Suporta weekly * (interval=1 ou 2 pra quinzenal) e custom_weekdays. * * @param {object} rule { start_date, interval, weekdays, type } * @param {number} max * @param {Set} exceptionDates * @returns {string[]} */ export function generateOccurrenceDates(rule, max, exceptionDates = new Set()) { const dates = []; const start = new Date(`${rule.start_date}T00:00:00`); const interval = Math.max(1, rule.interval || 1); const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()]; const isCustom = rule.type === 'custom_weekdays'; const cursor = new Date(start); let safety = 0; while (dates.length < max && safety < 365 * 3) { const iso = dateToISO(cursor); const dow = cursor.getDay(); const inWeekdays = weekdays.includes(dow); if (inWeekdays && !exceptionDates.has(iso)) { dates.push(iso); } if (isCustom) { cursor.setDate(cursor.getDate() + 1); } else if (inWeekdays) { cursor.setDate(cursor.getDate() + 7 * interval); } else { cursor.setDate(cursor.getDate() + 1); } safety++; } return dates; } /** * Decide se o dialog de confirmação de status change deve ser exibido. * * Pure: depende só do ctx montado por loadStatusChangeContext. * * Regras: * - faltou/cancelado: mostra se há regra de exceção com charge_mode != 'none' * OU pacote saldo/upfront * - realizado: mostra se há pending record OU pacote saldo * - agendado: (reverse) mostra se há artefatos a desfazer */ export function needsStatusConfirmDialog(status, ctx) { const isFaltouOrCancel = status === 'faltou' || status === 'cancelado'; const isRealizado = status === 'realizado'; const isAgendado = status === 'agendado'; const hasRegraComCobranca = ctx?.regraExcecao && ctx.regraExcecao.charge_mode !== 'none'; const isPacoteSaldo = ctx?.billingContract?.charging_style === 'saldo'; const isPacoteUpfront = ctx?.billingContract?.charging_style === 'upfront'; const hasPending = !!ctx?.pendingRecord; if (isFaltouOrCancel) { return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront; } if (isRealizado) { return hasPending || isPacoteSaldo; } if (isAgendado) { const r = ctx?.reverseArtifacts; if (!r) return false; const hasActiveRecords = (r.activeRecords?.length || 0) > 0; return hasActiveRecords || r.saldoConsumed; } return false; } // ── Loaders (read-only DB) ──────────────────────────────────────────────── /** * Carrega contexto pra decisão de status change. * * Read-only. Não dispara toast (caller decide). Tolerante a erros parciais * (loga warn e segue com null). * * @param {object} opts * @param {object} opts.supabase instância do client * @param {object} opts.row row do agenda_eventos (pode ser parcial — usa fallbacks) * @param {string} opts.eventoId uuid (null pra ocorrências virtuais não materializadas) * @param {string} opts.status 'realizado' | 'faltou' | 'cancelado' | 'agendado' * @param {string} opts.ownerId auth.uid() (resolvido pelo caller) * @param {string} opts.tenantId activeTenantId * * @returns {Promise<{ * regraExcecao, * billingContract, * pendingRecord, * existingPaidRecord, * reverseArtifacts: { previousStatus, activeRecords, saldoConsumed } | null * }>} */ export async function loadStatusChangeContext({ supabase, row, eventoId, status, ownerId, tenantId }) { const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null, existingPaidRecord: null, reverseArtifacts: null }; // 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation) const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' }; const excType = exceptionTypeMap[status]; if (excType && tenantId) { try { const { data } = await tenantDb().from('financial_exceptions') .select('*') .eq('exception_type', excType) .or(`owner_id.eq.${ownerId},owner_id.is.null`) .order('owner_id', { ascending: false, nullsLast: true }) .limit(1) .maybeSingle(); ctx.regraExcecao = data ?? null; } catch (e) { console.warn('[agendaBilling] regra de exceção:', e?.message); } } // 2) Billing contract — 3 caminhos: row.billing_contract_id direto → query // agenda_eventos.billing_contract_id (recém-materializada) → contrato // ativo do paciente (virtuais). const patientId = row?.patient_id ?? row?.paciente_id ?? null; const contractId = row?.billing_contract_id ?? null; if (contractId) { try { const { data } = await tenantDb().from('billing_contracts') .select('*') .eq('id', contractId) .maybeSingle(); ctx.billingContract = data ?? null; } catch (e) { console.warn('[agendaBilling] contract via id direto:', e?.message); } } if (!ctx.billingContract && eventoId) { try { const { data: ev } = await tenantDb().from('agenda_eventos') .select('billing_contract_id') .eq('id', eventoId) .maybeSingle(); if (ev?.billing_contract_id) { const { data: c } = await tenantDb().from('billing_contracts') .select('*') .eq('id', ev.billing_contract_id) .maybeSingle(); ctx.billingContract = c ?? null; } } catch (e) { console.warn('[agendaBilling] contract via agenda_evento:', e?.message); } } if (!ctx.billingContract && patientId && tenantId) { try { const { data: c } = await tenantDb().from('billing_contracts') .select('*') .eq('patient_id', patientId) .eq('status', 'active') .eq('type', 'package') .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); ctx.billingContract = c ?? null; } catch (e) { console.warn('[agendaBilling] contract via patient_id:', e?.message); } } // 3) Pending record if (eventoId) { try { const { data } = await tenantDb().from('financial_records') .select('*') .eq('agenda_evento_id', eventoId) .in('status', ['pending', 'overdue']) .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); ctx.pendingRecord = data ?? null; } catch (e) { console.warn('[agendaBilling] pending record:', e?.message); } } // 3b) Paid record pré-existente (caso C12: antecipar pagamento). if (eventoId) { try { const { data } = await tenantDb().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('[agendaBilling] existing paid record:', e?.message); } } // 4) Reverse transition (status novo='agendado'): artefatos a desfazer. if (status === 'agendado' && eventoId) { ctx.reverseArtifacts = { previousStatus: row?.status || null, activeRecords: [], saldoConsumed: false }; try { const { data: evRow } = await tenantDb().from('agenda_eventos') .select('status, billing_contract_id') .eq('id', eventoId) .maybeSingle(); if (evRow) { ctx.reverseArtifacts.previousStatus = evRow.status; } const { data: recs } = await tenantDb().from('financial_records') .select('id, status, amount, final_amount, description, paid_at, payment_method') .eq('agenda_evento_id', eventoId) .neq('status', 'cancelled') .order('created_at', { ascending: false }); ctx.reverseArtifacts.activeRecords = recs || []; // Heurística saldo consumido: billing_contract_id + previousStatus // ≠ 'agendado' + style=saldo. Falso positivo é mitigado pela escolha // do user no dialog de "devolver saldo". const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado'; ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo'; } catch (e) { console.warn('[agendaBilling] reverse artifacts:', e?.message); } } 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 tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().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( tenantDb().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( tenantDb().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, 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( tenantDb().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( tenantDb().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( tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().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 tenantDb().from('billing_contracts') .insert({ owner_id: normalized.owner_id, 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 tenantDb().from('agenda_eventos') .insert({ owner_id: rule.owner_id, 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 tenantDb().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 tenantDb().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, 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 tenantDb().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 } }; } }