agenda Fase B2: extrai mutations pro agendaBilling.service

Continua decomposicao da agenda. Extrai 3 mutations:
- applyStatusDecisions          (~330L — reverse, consume saldo,
                                  multa, mark paid, generate package
                                  charge, antecipated payment)
- createPackageContract         (~140L — upfront ou saldo)
- materializeAndChargePerSession (~90L — N events + N records)

Padrao das assinaturas:
- supabase como dep explicita (em vez de closure)
- toast OPCIONAL (callsite fora de UI pode passar null;
  applyStatusDecisions ramifica via `if (toast?.add)`)
- ownerId/tenantId como args (em vez de capturar refs)

createPackageContract + materializeAndChargePerSession ja retornavam
{ toast: {...} } pra caller mostrar — pattern preservado.

useMelissaAgenda.js: 2593L -> 2042L (-551L). 3 wrappers finos
injetam supabase/toast/refs do escopo do composable. Comportamento
identico — codigo movido linha-a-linha, so refactor de signature.

TOTAL nas fases A+B1+B2: -1525L extraidas do useMelissaAgenda
(de 3033L original pra 2042L atual). Tres pages (Melissa/Rail/
Clinica) agora podem reusar mesmo billing core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 09:37:09 -03:00
parent e7e3d1beb1
commit 049dd91b9b
2 changed files with 543 additions and 572 deletions
@@ -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
}
};
}
}