import crypto from 'node:crypto'; import { Prisma, PrismaClient } from '@lisglosips/database'; import type { ParsedCdrStreamEvent } from '@lisglosips/redis'; import { calculateCycleCharge, isBillableSipCode } from './billing.js'; export type RatingOutcome = 'rated' | 'skipped' | 'duplicate' | 'failed'; export interface RatingResult { outcome: RatingOutcome; rawCdrId: string; ratedCdrId?: string; customerFee: string; vendorCost: string; grossProfit: string; billSec: number; } interface StoredRawCdr { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED'; } interface StoredRatedCdr { id: string; rawCdrId: string; } interface StoredVendorGateway { id: string; vendorId: string; billingCycleSec: number; cycleRate: Prisma.Decimal; } interface LockedCustomer { id: string; balance: Prisma.Decimal; } export interface CdrRatingTransaction { findRawByEventId(eventId: string): Promise; createRaw(event: ParsedCdrStreamEvent, rawCdrId: string): Promise; findRatedByRawCdrId(rawCdrId: string): Promise; findVendorGateway(vendorGatewayId: string): Promise; lockCustomer(customerId: string): Promise; createRated(input: CreateRatedInput): Promise; markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise; updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise; } export interface CdrRatingStore { transaction(operation: (tx: CdrRatingTransaction) => Promise): Promise; } export interface CreateRatedInput { id: string; rawCdrId: string; billSec: number; customerFee: Prisma.Decimal; vendorCost: Prisma.Decimal; grossProfit: Prisma.Decimal; customerRate: Prisma.InputJsonValue; vendorRate: Prisma.InputJsonValue; } export class CdrRatingService { constructor(private readonly store: CdrRatingStore) {} async rate(event: ParsedCdrStreamEvent): Promise { return this.store.transaction(async (tx) => { const existing = await tx.findRawByEventId(event.event_id); if (existing) { const rated = await tx.findRatedByRawCdrId(existing.id); return { outcome: 'duplicate', rawCdrId: existing.id, ratedCdrId: rated?.id, customerFee: '0.000000', vendorCost: '0.000000', grossProfit: '0.000000', billSec: 0 }; } const raw = await tx.createRaw(event, prefixedId('raw')); if (!isBillableSipCode(event.sipCode) || event.durationSeconds <= 0) { await tx.markRawStatus(raw.id, 'SKIPPED'); return zeroResult('skipped', raw.id); } if (!billableId(event.customer_id) || !billableId(event.vendor_gateway_id)) { await tx.markRawStatus(raw.id, 'SKIPPED'); return zeroResult('skipped', raw.id); } const vendorGateway = await tx.findVendorGateway(event.vendor_gateway_id); const customer = await tx.lockCustomer(event.customer_id); if (!vendorGateway || !customer) { await tx.markRawStatus(raw.id, 'FAILED'); return zeroResult('failed', raw.id); } const vendorCharge = calculateCycleCharge({ durationSec: event.durationSeconds, billingCycleSec: vendorGateway.billingCycleSec, cycleRate: vendorGateway.cycleRate }); const customerFee = vendorCharge.amount; const vendorCost = vendorCharge.amount; const grossProfit = customerFee.minus(vendorCost).toDecimalPlaces(6); const afterBalance = customer.balance.minus(customerFee).toDecimalPlaces(6); const ratedId = prefixedId('rated'); await tx.createRated({ id: ratedId, rawCdrId: raw.id, billSec: vendorCharge.billSec, customerFee, vendorCost, grossProfit, customerRate: { source: 'S23_MINIMAL_MIRROR_VENDOR_RATE', billingCycleSec: vendorGateway.billingCycleSec, cycleRate: vendorGateway.cycleRate.toFixed(6) }, vendorRate: { source: 'vendor_gateway', vendorGatewayId: vendorGateway.id, billingCycleSec: vendorGateway.billingCycleSec, cycleRate: vendorGateway.cycleRate.toFixed(6) } }); await tx.updateCustomerBalance(customer.id, afterBalance); await tx.markRawStatus(raw.id, 'RATED'); return { outcome: 'rated', rawCdrId: raw.id, ratedCdrId: ratedId, customerFee: customerFee.toFixed(6), vendorCost: vendorCost.toFixed(6), grossProfit: grossProfit.toFixed(6), billSec: vendorCharge.billSec }; }); } } export class PrismaCdrRatingStore implements CdrRatingStore { constructor(private readonly prisma: PrismaClient) {} async transaction(operation: (tx: CdrRatingTransaction) => Promise): Promise { return this.prisma.$transaction(async (tx) => operation(new PrismaCdrRatingTransaction(tx)), { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30000 }); } } class PrismaCdrRatingTransaction implements CdrRatingTransaction { constructor(private readonly tx: Prisma.TransactionClient) {} async findRawByEventId(eventId: string): Promise { return this.tx.rawCdr.findUnique({ where: { eventId: storageEventId(eventId) }, select: { id: true, eventId: true, ratingStatus: true } }); } async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string): Promise { return this.tx.rawCdr.create({ data: { id: rawCdrId, eventId: storageEventId(event.event_id), callId: event.call_id, customerId: normalizeCdrNullableId(event.customer_id), customerGatewayId: normalizeCdrNullableId(event.customer_gateway_id), customerGatewayPolicyId: normalizeCdrNullableId(event.customer_gateway_policy_id), sourceIp: emptyToNull(event.source_ip), caller: event.caller || 'unknown', callee: event.callee || 'unknown', rawCallee: emptyToNull(event.raw_callee), businessPrefixId: normalizeCdrNullableId(event.business_prefix_id), businessPrefix: emptyToNull(event.business_prefix), calleeCityCode: emptyToNull(event.callee_city_code), calleeCityName: emptyToNull(event.callee_city_name), calleeProvinceName: emptyToNull(event.callee_province_name), calleeOperator: numberCarrier(event.callee_operator), calleeNumberType: phoneNumberType(event.callee_number_type), vendorId: normalizeCdrNullableId(event.vendor_id), vendorGatewayId: normalizeCdrNullableId(event.vendor_gateway_id), lineGroupId: normalizeCdrNullableId(event.line_group_id), landingCaller: emptyToNull(event.landing_caller), landingCallee: emptyToNull(event.landing_callee), startedAt: parseCdrDate(event.started_at, event.created_at), answeredAt: parseOptionalCdrDate(event.answered_at), endedAt: parseCdrDate(event.ended_at, event.created_at), durationSec: event.durationSeconds, sipCode: event.sipCode, hangupReason: emptyToNull(event.hangup_reason), recordingKey: emptyToNull(event.recording_key), configVersion: parseOptionalInt(event.config_version), ratingStatus: 'UNRATED', payload: eventToJson(event) }, select: { id: true, eventId: true, ratingStatus: true } }); } async findRatedByRawCdrId(rawCdrId: string): Promise { return this.tx.ratedCdr.findUnique({ where: { rawCdrId }, select: { id: true, rawCdrId: true } }); } async findVendorGateway(vendorGatewayId: string): Promise { return this.tx.vendorGateway.findUnique({ where: { id: vendorGatewayId }, select: { id: true, vendorId: true, billingCycleSec: true, cycleRate: true } }); } async lockCustomer(customerId: string): Promise { const [customer] = await this.tx.$queryRaw`SELECT id, balance FROM customers WHERE id = ${customerId} AND deleted_at IS NULL FOR UPDATE`; return customer ?? null; } async createRated(input: CreateRatedInput): Promise { return this.tx.ratedCdr.create({ data: input, select: { id: true, rawCdrId: true } }); } async markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise { await this.tx.rawCdr.update({ where: { id: rawCdrId }, data: { ratingStatus: status } }); } async updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise { await this.tx.customer.update({ where: { id: customerId }, data: { balance, updatedBy: 'worker-cdr', version: { increment: 1 } } }); } } function storageEventId(eventId: string): string { if (eventId.length <= 64) { return eventId; } const hash = crypto.createHash('sha256').update(eventId).digest('hex').slice(0, 16); return `${eventId.slice(0, 47)}-${hash}`; } function zeroResult(outcome: RatingOutcome, rawCdrId: string): RatingResult { return { outcome, rawCdrId, customerFee: '0.000000', vendorCost: '0.000000', grossProfit: '0.000000', billSec: 0 }; } function prefixedId(prefix: string): string { return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32)}`; } function billableId(value: string): boolean { return Boolean(normalizeCdrNullableId(value)); } export function normalizeCdrNullableId(value: string): string | null { const normalized = value.trim(); if ( !normalized || normalized === 'none' || normalized === 'unknown' || normalized === 'no_active_version' || normalized === 'no_policy_match' || normalized === 'single_gateway' ) { return null; } return normalized; } function emptyToNull(value: string): string | null { const normalized = value.trim(); return !normalized || normalized === 'none' ? null : normalized; } function parseOptionalInt(value: string): number | null { if (!value || value === 'none') { return null; } const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed >= -2147483648 && parsed <= 2147483647 ? parsed : null; } function parseOptionalCdrDate(value: string): Date | null { if (!value || value === 'none') { return null; } return parseCdrDate(value, value); } function parseCdrDate(value: string, fallback: string): Date { const candidate = value && value !== 'none' ? value : fallback; if (/^\d+$/.test(candidate)) { return new Date(Number.parseInt(candidate, 10) * 1000); } const parsed = new Date(candidate); if (Number.isNaN(parsed.getTime())) { return new Date(); } return parsed; } function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue { return { schema_version: event.schema_version, event_id: event.event_id, idempotency_key: event.idempotency_key, call_id: event.call_id, node_id: event.node_id, opensips_instance: event.opensips_instance, ingress_a_ip: event.ingress_a_ip, rtpengine_node: event.rtpengine_node, source_ip: event.source_ip, caller: event.caller, callee: event.callee, raw_callee: event.raw_callee, business_prefix_id: event.business_prefix_id, business_prefix: event.business_prefix, landing_caller: event.landing_caller, landing_callee: event.landing_callee, normalized_callee: event.normalized_callee, callee_city_code: event.callee_city_code, callee_city_name: event.callee_city_name, callee_province_name: event.callee_province_name, callee_operator: event.callee_operator, callee_number_type: event.callee_number_type, customer_id: event.customer_id, customer_gateway_id: event.customer_gateway_id, customer_gateway_policy_id: event.customer_gateway_policy_id, vendor_id: event.vendor_id, vendor_gateway_id: event.vendor_gateway_id, line_group_id: event.line_group_id, started_at: event.started_at, answered_at: event.answered_at, ended_at: event.ended_at, duration_sec: event.duration_sec, sip_code: event.sip_code, hangup_reason: event.hangup_reason, recording_key: event.recording_key, config_version: event.config_version, created_at: event.created_at }; } function numberCarrier(value: string): 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN' { return value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' ? value : 'UNKNOWN'; } function phoneNumberType(value: string): 'MOBILE' | 'LANDLINE' | 'INTERNATIONAL' | 'UNKNOWN' { return value === 'MOBILE' || value === 'LANDLINE' || value === 'INTERNATIONAL' ? value : 'UNKNOWN'; }