Fecha a orquestracao de Billing (agente.md secao 120-139) sobre o schema/ RatingEngine puro ja existentes: escritores do ledger UsageEvent (CALL_SECONDS no CDR, ACTIVE_DAY via sweep diario), closeBillingPeriod/ reopenBillingPeriod (fechamento imutavel com audit trail), e os controllers de price books/rate decks/plan versions/subscriptions/ periods/statements. Corrige 2 bugs reais de RLS achados no teste ponta a ponta (reopen sem tenant context, subscriptions sem withTenantContext) e adiciona teste unitario do RatingEngine (17 casos). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
194 lines
6.5 KiB
TypeScript
194 lines
6.5 KiB
TypeScript
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
|
import type { NormalizedEvent } from "@b2bcall/telephony";
|
|
import { createLogger } from "@b2bcall/shared";
|
|
|
|
const logger = createLogger("b2bcall-fs-events");
|
|
|
|
function extractId(fsName: string | undefined): string | undefined {
|
|
return fsName?.split("@")[0];
|
|
}
|
|
|
|
function toDate(epochSeconds: unknown): Date | undefined {
|
|
const n = typeof epochSeconds === "string" ? Number(epochSeconds) : undefined;
|
|
return n && !Number.isNaN(n) ? new Date(n * 1000) : undefined;
|
|
}
|
|
|
|
function seconds(a: Date | null | undefined, b: Date | null | undefined): number | undefined {
|
|
if (!a || !b) return undefined;
|
|
return Math.max(0, Math.round((b.getTime() - a.getTime()) / 1000));
|
|
}
|
|
|
|
/** Eventos sem relação 1:1 com uma chamada específica (estado de gateway,
|
|
* registro de ramal, snapshot de fila) não geram/atualizam `Call`. */
|
|
const CALL_RELATED_TYPES = new Set<NormalizedEvent["type"]>([
|
|
"CALL_CREATED",
|
|
"CALL_RINGING",
|
|
"CALL_ANSWERED",
|
|
"CALL_BRIDGED",
|
|
"CALL_UNBRIDGED",
|
|
"CALL_ENDED",
|
|
"AGENT_OFFERED_CALL",
|
|
"AGENT_BRIDGE_FAILED",
|
|
"QUEUE_MEMBER_LEFT",
|
|
]);
|
|
|
|
/**
|
|
* Persiste o rastro de uma chamada em `calls`/`call_legs`/`call_events`
|
|
* (agente.md secao 152-154) — o canal Redis `b2bcall:events` é efêmero
|
|
* (pub/sub sem histórico), isso aqui é o registro durável por trás dos
|
|
* relatórios (secao 157-160). `Call.id` é o próprio `freeswitch_uuid` (sem
|
|
* suporte a transferência entre uuids nesta fase — ver docs/CDR.md).
|
|
*
|
|
* Só roda pra eventos com `tenantId` já resolvido (direto via channel
|
|
* variable, ou pelo fan-out de tenant-resolve.ts) — sem tenant não dá pra
|
|
* saber em qual RLS context escrever.
|
|
*/
|
|
export async function persistCallEvent(normalized: NormalizedEvent): Promise<void> {
|
|
if (!normalized.tenantId || !CALL_RELATED_TYPES.has(normalized.type)) {
|
|
return;
|
|
}
|
|
|
|
// Achado real: agent-offering/bridge-agent-fail disparam de uma thread
|
|
// interna do mod_callcenter (outbound_agent_thread_run), sem contexto de
|
|
// channel — não têm header Unique-ID, então `normalized.callUuid` fica
|
|
// undefined pra esses dois tipos (diferente de member-queue-end, que
|
|
// dispara no channel do member e tem Unique-ID normalmente). O
|
|
// `CC-Member-Session-UUID` (`data.memberSessionUuid`) é o mesmo uuid do
|
|
// channel member em todos os casos — fallback confiável.
|
|
const callId = normalized.callUuid ?? (normalized.data.memberSessionUuid as string | undefined);
|
|
if (!callId) return;
|
|
|
|
const prisma = getPrismaClient();
|
|
const tenantId = normalized.tenantId;
|
|
|
|
try {
|
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
|
const patch = buildPatch(normalized);
|
|
|
|
await tx.call.upsert({
|
|
where: { id: callId },
|
|
create: {
|
|
id: callId,
|
|
tenantId,
|
|
freeswitchUuid: callId,
|
|
attemptId: normalized.b2bcallAttemptId,
|
|
campaignId: normalized.b2bcallCampaignId,
|
|
leadId: normalized.b2bcallLeadId,
|
|
direction: normalized.b2bcallCampaignId ? "OUTBOUND" : "INTERNAL",
|
|
createdAt: new Date(normalized.occurredAt),
|
|
...patch,
|
|
},
|
|
update: patch,
|
|
});
|
|
|
|
await tx.callEvent.create({
|
|
data: {
|
|
tenantId,
|
|
callId,
|
|
type: normalized.type,
|
|
occurredAt: new Date(normalized.occurredAt),
|
|
data: normalized.data as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
if (normalized.type === "CALL_ENDED") {
|
|
await finalizeCall(tx, callId, tenantId);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
logger.error("falha ao persistir evento de chamada", {
|
|
error: String(err),
|
|
type: normalized.type,
|
|
callId,
|
|
});
|
|
}
|
|
}
|
|
|
|
type CallPatch = Partial<{
|
|
progressAt: Date;
|
|
answerAt: Date;
|
|
bridgeAt: Date;
|
|
agentAnswerAt: Date;
|
|
queueEnterAt: Date;
|
|
queueId: string;
|
|
agentId: string;
|
|
endAt: Date;
|
|
hangupCause: string;
|
|
}>;
|
|
|
|
function buildPatch(normalized: NormalizedEvent): CallPatch {
|
|
switch (normalized.type) {
|
|
case "CALL_RINGING":
|
|
return { progressAt: new Date(normalized.occurredAt) };
|
|
|
|
case "CALL_ANSWERED":
|
|
return { answerAt: new Date(normalized.occurredAt) };
|
|
|
|
case "CALL_BRIDGED":
|
|
// Simplificação: quando a chamada tem fila associada, o primeiro
|
|
// bridge É o agente atendendo — não distinguimos bridge pra IVR/
|
|
// AVMD de bridge pro agente nesta fase (ver docs/CDR.md).
|
|
return { bridgeAt: new Date(normalized.occurredAt), agentAnswerAt: new Date(normalized.occurredAt) };
|
|
|
|
case "AGENT_OFFERED_CALL": {
|
|
const queueId = extractId(normalized.data.queue as string | undefined);
|
|
const agentId = extractId(normalized.data.agent as string | undefined);
|
|
return { queueId, agentId };
|
|
}
|
|
|
|
case "AGENT_BRIDGE_FAILED":
|
|
return { hangupCause: normalized.data.hangupCause as string | undefined };
|
|
|
|
case "QUEUE_MEMBER_LEFT": {
|
|
const joinedAt = toDate(normalized.data.joinedAt);
|
|
const queueId = extractId(normalized.data.queue as string | undefined);
|
|
return { queueEnterAt: joinedAt, queueId };
|
|
}
|
|
|
|
case "CALL_ENDED":
|
|
return { endAt: new Date(normalized.occurredAt), hangupCause: normalized.data.hangupCause as string | undefined };
|
|
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada
|
|
* terminou — nunca antes, pra não gravar valores parciais. */
|
|
async function finalizeCall(tx: Prisma.TransactionClient, callId: string, tenantId: string): Promise<void> {
|
|
const call = await tx.call.findUniqueOrThrow({ where: { id: callId } });
|
|
const talkTime = seconds(call.bridgeAt, call.endAt);
|
|
const billableSeconds = talkTime ?? 0;
|
|
|
|
await tx.call.update({
|
|
where: { id: callId },
|
|
data: {
|
|
ringTime: seconds(call.createdAt, call.answerAt),
|
|
waitTime: seconds(call.queueEnterAt, call.agentAnswerAt),
|
|
talkTime,
|
|
durationSeconds: seconds(call.createdAt, call.endAt),
|
|
billableSeconds,
|
|
},
|
|
});
|
|
|
|
// "Chamada faturável" (agente.md secao 133) gera o UsageEvent que o
|
|
// RatingEngine (packages/billing) consome no fechamento do período —
|
|
// nunca calcula o valor aqui, só registra o fato bruto (segundos
|
|
// faturáveis). Chamada sem talk time (nunca bridgeou) não gera evento —
|
|
// nada a cobrar.
|
|
if (billableSeconds > 0) {
|
|
await tx.usageEvent.create({
|
|
data: {
|
|
tenantId,
|
|
callId,
|
|
meter: "CALL_SECONDS",
|
|
quantity: billableSeconds,
|
|
unit: "seconds",
|
|
sourceType: "call",
|
|
sourceId: callId,
|
|
occurredAt: call.endAt ?? new Date(),
|
|
},
|
|
});
|
|
}
|
|
}
|