Files
lisglosips/apps/worker-cdr/src/rating.ts
T

381 lines
13 KiB
TypeScript

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<StoredRawCdr | null>;
createRaw(event: ParsedCdrStreamEvent, rawCdrId: string): Promise<StoredRawCdr>;
findRatedByRawCdrId(rawCdrId: string): Promise<StoredRatedCdr | null>;
findVendorGateway(vendorGatewayId: string): Promise<StoredVendorGateway | null>;
lockCustomer(customerId: string): Promise<LockedCustomer | null>;
createRated(input: CreateRatedInput): Promise<StoredRatedCdr>;
markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void>;
updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void>;
}
export interface CdrRatingStore {
transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T>;
}
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<RatingResult> {
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<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
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<StoredRawCdr | null> {
return this.tx.rawCdr.findUnique({
where: { eventId: storageEventId(eventId) },
select: { id: true, eventId: true, ratingStatus: true }
});
}
async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string): Promise<StoredRawCdr> {
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<StoredRatedCdr | null> {
return this.tx.ratedCdr.findUnique({
where: { rawCdrId },
select: { id: true, rawCdrId: true }
});
}
async findVendorGateway(vendorGatewayId: string): Promise<StoredVendorGateway | null> {
return this.tx.vendorGateway.findUnique({
where: { id: vendorGatewayId },
select: { id: true, vendorId: true, billingCycleSec: true, cycleRate: true }
});
}
async lockCustomer(customerId: string): Promise<LockedCustomer | null> {
const [customer] = await this.tx.$queryRaw<LockedCustomer[]>`SELECT id, balance FROM customers WHERE id = ${customerId} AND deleted_at IS NULL FOR UPDATE`;
return customer ?? null;
}
async createRated(input: CreateRatedInput): Promise<StoredRatedCdr> {
return this.tx.ratedCdr.create({
data: input,
select: { id: true, rawCdrId: true }
});
}
async markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void> {
await this.tx.rawCdr.update({
where: { id: rawCdrId },
data: { ratingStatus: status }
});
}
async updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void> {
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';
}