Files
agenciapsilmno/src/features/financeiro/services/billingContractsRepository.js
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

138 lines
5.2 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/billingContractsRepository.js
|
| Repository de billing_contracts — pacotes/contratos de cobrança (upfront,
| pagamento por sessão, etc).
|
| Gotcha conhecido (memória project_billing_contracts_no_updated_at):
| billing_contracts NÃO tem coluna updated_at. UPDATE com updated_at falha
| silently em Promise.allSettled. Repository NÃO inclui updated_at em patches.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { BILLING_CONTRACT_SELECT } from './financialSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista contratos ativos do paciente.
*/
export async function listForPatient(patientId, { tenantId, includeDeleted = false } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
let q = tenantDb().from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('patient_id', patientId).order('created_at', { ascending: false });
if (!includeDeleted) q = q.is('deleted_at', null);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* Lê contrato por id. Refresh FRESH do banco — usado pelo orchestrator antes
* de UPDATE pra evitar race condition (memória project_agenda_reverse_transitions).
*/
export async function getById(contractId, { tenantId } = {}) {
if (!contractId) throw new Error('contractId obrigatório.');
const tid = resolveTenantId(tenantId);
const { data, error } = await tenantDb().from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria contrato.
*/
export async function create(payload) {
if (!payload?.patient_id) throw new Error('patient_id obrigatório.');
if (!payload?.charging_style) throw new Error('charging_style obrigatório.');
const uid = await getUid();
const tid = resolveTenantId(payload.tenantId);
const row = {
owner_id: uid,
patient_id: payload.patient_id,
charging_style: payload.charging_style,
sessions_total: payload.sessions_total ?? null,
sessions_used: 0,
total_amount: payload.total_amount != null ? Number(payload.total_amount) : null,
status: payload.status || 'active',
start_date: payload.start_date || null,
end_date: payload.end_date || null
};
const { data, error } = await tenantDb().from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single();
if (error) throw error;
return data;
}
/**
* Update — NÃO INCLUI updated_at (tabela não tem essa coluna — gotcha conhecido).
*/
export async function update(contractId, patch, { tenantId } = {}) {
if (!contractId) throw new Error('contractId obrigatório.');
const tid = resolveTenantId(tenantId);
// eslint-disable-next-line no-unused-vars
const { updated_at: _dropped, ...safePatch } = patch || {};
const { data, error } = await tenantDb().from('billing_contracts').update(safePatch).eq('id', contractId).select(BILLING_CONTRACT_SELECT).single();
if (error) throw error;
return data;
}
/**
* Incrementa sessions_used em 1 (consume).
* Lê FRESH antes do UPDATE pra evitar race.
*/
export async function incrementSessionsUsed(contractId, { tenantId } = {}) {
const current = await getById(contractId, { tenantId });
if (!current) throw new Error('Contrato não encontrado.');
const newCount = (Number(current.sessions_used) || 0) + 1;
return update(contractId, { sessions_used: newCount }, { tenantId });
}
/**
* Decrementa sessions_used (reverse). Lê FRESH antes.
*/
export async function decrementSessionsUsed(contractId, { tenantId } = {}) {
const current = await getById(contractId, { tenantId });
if (!current) throw new Error('Contrato não encontrado.');
const newCount = Math.max(0, (Number(current.sessions_used) || 0) - 1);
return update(contractId, { sessions_used: newCount }, { tenantId });
}
/**
* Busca records cross-week por recurrence_id (memória project_cross_week_propagation).
* Útil pra bulk-load de pacote upfront.
*/
export async function findRecordsByRecurrence(recurrenceId, { tenantId } = {}) {
if (!recurrenceId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await tenantDb().from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').is('deleted_at', null).not('agenda_evento_id', 'is', null);
// NOTE: filter por recurrence_id requer join — fica como TODO no orchestrator
// (memória project_cross_week_propagation: query records cross-week por recurrence_id).
if (error) throw error;
return data || [];
}