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:
@@ -293,3 +293,524 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
|
|||||||
|
|
||||||
return ctx;
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||||
import { pickColor } from '@/features/agenda/utils/colors';
|
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 {
|
import {
|
||||||
computeSeriePrice as _computeSeriePrice,
|
computeSeriePrice as _computeSeriePrice,
|
||||||
generateOccurrenceDates as _generateOccurrenceDates,
|
generateOccurrenceDates as _generateOccurrenceDates,
|
||||||
loadStatusChangeContext,
|
loadStatusChangeContext,
|
||||||
needsStatusConfirmDialog
|
needsStatusConfirmDialog,
|
||||||
|
applyStatusDecisions,
|
||||||
|
createPackageContract as _createPackageContractService,
|
||||||
|
materializeAndChargePerSession as _materializeAndChargePerSessionService
|
||||||
} from '@/features/agenda/services/agendaBilling.service';
|
} from '@/features/agenda/services/agendaBilling.service';
|
||||||
|
|
||||||
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
||||||
@@ -1293,337 +1296,22 @@ function _buildHandlers(deps) {
|
|||||||
const _needsConfirmDialog = needsStatusConfirmDialog;
|
const _needsConfirmDialog = needsStatusConfirmDialog;
|
||||||
|
|
||||||
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
|
// 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 }) {
|
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
|
||||||
const tenantId = clinicTenantId.value;
|
return applyStatusDecisions({
|
||||||
const uid = ownerId.value;
|
supabase,
|
||||||
const patientId = row.patient_id ?? row.paciente_id ?? null;
|
toast,
|
||||||
const tasks = [];
|
eventoId,
|
||||||
|
row,
|
||||||
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
|
novoStatus,
|
||||||
// Tratado antes dos blocos forward porque a lógica é distinta —
|
ctx,
|
||||||
// cancelar records, devolver saldo, sem multa nova. Status já foi
|
decision,
|
||||||
// atualizado pelo _applyStatusUpdateOnly antes desta função.
|
ownerId: ownerId.value,
|
||||||
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
|
tenantId: clinicTenantId.value
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
|
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
|
||||||
const onDialogSave = _buildOnDialogSave(deps);
|
const onDialogSave = _buildOnDialogSave(deps);
|
||||||
@@ -1797,7 +1485,8 @@ function _buildOnDialogSave(deps) {
|
|||||||
let chargeInfo = null;
|
let chargeInfo = null;
|
||||||
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
|
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
|
||||||
if (recChargeMode === 'package') {
|
if (recChargeMode === 'package') {
|
||||||
chargeInfo = await _createPackageContract({
|
chargeInfo = await _createPackageContractService({
|
||||||
|
supabase,
|
||||||
rule: createdRule,
|
rule: createdRule,
|
||||||
normalized,
|
normalized,
|
||||||
recorrencia,
|
recorrencia,
|
||||||
@@ -1807,7 +1496,7 @@ function _buildOnDialogSave(deps) {
|
|||||||
markPaidNow: arg?.markPaidNow === true
|
markPaidNow: arg?.markPaidNow === true
|
||||||
});
|
});
|
||||||
} else if (recChargeMode === 'per_session') {
|
} 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.
|
// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
|
||||||
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.
|
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.
|
||||||
|
|||||||
Reference in New Issue
Block a user