Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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>;
|
||||
createCustomerCharge(input: CreateCustomerChargeInput): 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 interface CreateCustomerChargeInput {
|
||||
id: string;
|
||||
customerId: string;
|
||||
rawCdrId: string;
|
||||
eventId: string;
|
||||
amount: Prisma.Decimal;
|
||||
beforeBalance: Prisma.Decimal;
|
||||
afterBalance: Prisma.Decimal;
|
||||
}
|
||||
|
||||
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.createCustomerCharge({
|
||||
id: prefixedId('cdrchg'),
|
||||
customerId: customer.id,
|
||||
rawCdrId: raw.id,
|
||||
eventId: event.event_id,
|
||||
amount: customerFee.negated().toDecimalPlaces(6),
|
||||
beforeBalance: customer.balance,
|
||||
afterBalance
|
||||
});
|
||||
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: nullableId(event.customer_id),
|
||||
customerGatewayId: nullableId(event.customer_gateway_id),
|
||||
customerGatewayPolicyId: nullableId(event.customer_gateway_policy_id),
|
||||
sourceIp: emptyToNull(event.source_ip),
|
||||
caller: event.caller || 'unknown',
|
||||
callee: event.callee || 'unknown',
|
||||
vendorId: nullableId(event.vendor_id),
|
||||
vendorGatewayId: nullableId(event.vendor_gateway_id),
|
||||
lineGroupId: nullableId(event.line_group_id),
|
||||
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 createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
|
||||
await this.tx.customerRecharge.create({
|
||||
data: {
|
||||
id: input.id,
|
||||
customerId: input.customerId,
|
||||
amount: input.amount,
|
||||
beforeBalance: input.beforeBalance,
|
||||
afterBalance: input.afterBalance,
|
||||
idempotencyKey: `cdr:${input.eventId}`,
|
||||
remark: `CDR_CHARGE:${input.rawCdrId}`,
|
||||
status: 'SUCCEEDED',
|
||||
createdBy: 'worker-cdr'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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(nullableId(value));
|
||||
}
|
||||
|
||||
function nullableId(value: string): string | null {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === 'none' || normalized === 'unknown' || normalized === 'no_active_version') {
|
||||
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 : 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,
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user