M4: financeiro foundation — services + composables paralelo
Modulo 4 da Fase 1. 9 arquivos novos em features/financeiro/: 4 services (_tenantGuards, financialSelects, financialRecords Repository, financialExceptionsRepository, billingContractsRepository) + 4 composables (useFinancialRecords, useFinancialExceptions, useBillingContracts, useBillingOrchestrator). Old composables ainda em paralelo — Fase C (cutover) bloqueada pelas decisoes #2/#3/#6 de billing (memoria agenda_billing_decisoes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 { 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 = supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('tenant_id', tid).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 supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).eq('tenant_id', tid).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 = {
|
||||
tenant_id: tid,
|
||||
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 supabase.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 supabase.from('billing_contracts').update(safePatch).eq('id', contractId).eq('tenant_id', tid).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 supabase.from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').eq('tenant_id', tid).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 || [];
|
||||
}
|
||||
Reference in New Issue
Block a user