agenda Fase B1: agendaBilling.service (read-only + helpers puros)

Continua decomposicao da agenda (apos Fase A utils). Extrai pro
service os componentes read-only / pure:

- computeSeriePrice          (puro)
- generateOccurrenceDates    (puro)
- loadStatusChangeContext    (read-only DB — assina supabase,
                              ownerId, tenantId, row, eventoId,
                              status)
- needsStatusConfirmDialog   (puro — depende so do ctx)

useMelissaAgenda.js: 2792L -> 2593L (-199L). _loadStatusChangeContext
agora e wrapper fino que injeta supabase/ownerId/tenantId do
composable scope. _needsConfirmDialog vira alias direto.
_computeSeriePrice/_generateOccurrenceDates importados direto.

Fase B1 deixa Rail/Clínica capazes de reusar TODA a logica
read-only de status change. Mutations (applyStatusDecisions,
createPackageContract, materializeAndChargePerSession) ficam pra
Fase B2.

Risco: zero comportamental — toda chamada produz o mesmo ctx
de antes. Codigo movido sem mudancas de logica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 09:30:06 -03:00
parent aa587e849c
commit e7e3d1beb1
2 changed files with 318 additions and 222 deletions
@@ -0,0 +1,295 @@
/*
|--------------------------------------------------------------------------
| 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';
// ── 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<string>} 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 supabase
.from('financial_exceptions')
.select('*')
.eq('tenant_id', tenantId)
.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 supabase
.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 supabase
.from('agenda_eventos')
.select('billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (ev?.billing_contract_id) {
const { data: c } = await supabase
.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 supabase
.from('billing_contracts')
.select('*')
.eq('tenant_id', tenantId)
.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 supabase
.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 supabase
.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 supabase
.from('agenda_eventos')
.select('status, billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status;
}
const { data: recs } = await supabase
.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;
}
@@ -52,6 +52,14 @@ import { pickDbFields } from '@/features/agenda/utils/dbFields';
import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers';
import { pickColor } from '@/features/agenda/utils/colors';
// ─── Service de billing (Fase B1: read-only + helpers puros) ────────────────
import {
computeSeriePrice as _computeSeriePrice,
generateOccurrenceDates as _generateOccurrenceDates,
loadStatusChangeContext,
needsStatusConfirmDialog
} from '@/features/agenda/services/agendaBilling.service';
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
@@ -1267,186 +1275,22 @@ function _buildHandlers(deps) {
}
// Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
// Wrapper fino sobre o service (Fase B1) — injeta supabase, ownerId, tenantId
// do escopo do composable. Lógica pura mora em agendaBilling.service.
async function _loadStatusChangeContext({ row, eventoId, status }) {
const ctx = { regraExcecao: null, billingContract: null, pendingRecord: 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 && clinicTenantId.value) {
try {
const { data } = await supabase
.from('financial_exceptions')
.select('*')
.eq('tenant_id', clinicTenantId.value)
.eq('exception_type', excType)
.or(`owner_id.eq.${ownerId.value},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true })
.limit(1)
.maybeSingle();
ctx.regraExcecao = data ?? null;
} catch (e) {
console.warn('[Fase5] erro carregando regra de exceção:', e?.message);
}
return loadStatusChangeContext({
supabase,
row,
eventoId,
status,
ownerId: ownerId.value,
tenantId: clinicTenantId.value
});
}
// 2) Billing contract — tenta 3 caminhos:
// (a) row.billing_contract_id direto (sessão real materializada)
// (b) eventoId real → query agenda_eventos.billing_contract_id
// (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente
const patientId = row.patient_id ?? row.paciente_id ?? null;
const contractId = row.billing_contract_id ?? null;
if (contractId) {
try {
const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle();
ctx.billingContract = data ?? null;
} catch (e) {
console.warn('[Fase5] erro contract via id direto:', e?.message);
}
}
if (!ctx.billingContract && eventoId) {
// Sessão real materializada — pode ter billing_contract_id no DB mesmo
// que a row passada não tenha (caso de virtual recém-materializada).
try {
const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle();
if (ev?.billing_contract_id) {
const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle();
ctx.billingContract = c ?? null;
}
} catch (e) {
console.warn('[Fase5] erro contract via agenda_evento:', e?.message);
}
}
if (!ctx.billingContract && patientId && clinicTenantId.value) {
// Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo
// do paciente. MVP assume 1 contrato active por paciente; pega o
// mais recente caso haja mais de um.
try {
const { data: c } = await supabase
.from('billing_contracts')
.select('*')
.eq('tenant_id', clinicTenantId.value)
.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('[Fase5] erro contract via patient_id:', e?.message);
}
}
// 3) Pending record (se evento já existe e tem cobrança pendente)
if (eventoId) {
try {
const { data } = await supabase
.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('[Fase5] erro pending record:', e?.message);
}
}
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
// Quando user antecipou paga ANTES de marcar Realizada, o record paid
// já existe ao tempo do status change. Dialog precisa saber pra:
// - Não oferecer "Gerar cobrança nova" (geraria duplicidade)
// - Ainda incrementar sessions_used (a sessão consome saldo do pacote)
if (eventoId) {
try {
const { data } = await supabase
.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('[Fase5] erro existing paid record:', e?.message);
}
}
// 4) Reverse transition (status novo='agendado'): carrega artefatos
// a desfazer — current status + ALL records ativos + saldo consumido.
// Sem isso, voltar pra agendado deixa multa/record/saldo órfão.
if (status === 'agendado' && eventoId) {
ctx.reverseArtifacts = {
previousStatus: row?.status || null,
activeRecords: [],
saldoConsumed: false
};
try {
// Status atual do DB (fonte autoritativa, row pode estar stale)
const { data: evRow } = await supabase
.from('agenda_eventos')
.select('status, billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status;
}
// Todos records NÃO cancelled vinculados (pending + overdue + paid)
const { data: recs } = await supabase
.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 || [];
// Detecta saldo consumido: evento pertence a pacote saldo e
// está em status que tipicamente consome (realizado, ou faltou/
// cancelado se default_consume_on_miss=true e foi aplicado).
// Heurística simples: se billing_contract_id está set + style=saldo
// + status anterior ≠ 'agendado', assume consumido. Se for falso
// positivo, user pode escolher "não devolver" no dialog.
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
} catch (e) {
console.warn('[Fase5] erro reverse artifacts:', e?.message);
}
}
return ctx;
}
// Precisa dialog? Sim se há regra de exceção com charge_mode != 'none'
// OU pacote saldo OU pacote upfront OU pending record (realizado).
function _needsConfirmDialog(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) {
// Mostra se há regra ou se é pacote saldo (pra perguntar consume)
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
}
if (isRealizado) {
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
return hasPending || isPacoteSaldo;
}
if (isAgendado) {
// Reverse transition: mostra se há artefatos a desfazer
const r = ctx.reverseArtifacts;
if (!r) return false;
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
return hasActiveRecords || r.saldoConsumed;
}
return false;
}
// _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog
// do agendaBilling.service. Pure — sem deps de composable state.
const _needsConfirmDialog = needsStatusConfirmDialog;
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
@@ -2511,18 +2355,7 @@ function _buildOnDialogDelete(deps) {
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
// Calcula valor total da série a partir dos commitmentItems.
function _computeSeriePrice(recorrencia) {
const items = recorrencia.commitmentItems || [];
const n = recorrencia.qtdSessoes;
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
};
}
// _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
@@ -2756,37 +2589,5 @@ async function _materializeAndChargePerSession({ rule, normalized, recorrencia,
}
}
// Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas
// em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1
// ou 2 pra quinzenal) e custom_weekdays.
function _generateOccurrenceDates(rule, max, exceptionDates) {
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;
// Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14.
// Para custom_weekdays, avança 1 dia e filtra weekdays.includes.
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) {
// weekly/quinzenal: ao bater o dow, pula interval semanas
cursor.setDate(cursor.getDate() + 7 * interval);
} else {
cursor.setDate(cursor.getDate() + 1);
}
safety++;
}
return dates;
}
// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.