Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
|
||||
export interface CycleRateInput {
|
||||
durationSec: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: Prisma.Decimal | string | number;
|
||||
}
|
||||
|
||||
export interface CycleCharge {
|
||||
billSec: number;
|
||||
cycles: number;
|
||||
amount: Prisma.Decimal;
|
||||
}
|
||||
|
||||
export function calculateCycleCharge(input: CycleRateInput): CycleCharge {
|
||||
if (!Number.isInteger(input.durationSec) || input.durationSec < 0) {
|
||||
throw new Error(`Invalid durationSec: ${input.durationSec}`);
|
||||
}
|
||||
if (!Number.isInteger(input.billingCycleSec) || input.billingCycleSec <= 0 || input.billingCycleSec > 60) {
|
||||
throw new Error(`Invalid billingCycleSec: ${input.billingCycleSec}`);
|
||||
}
|
||||
|
||||
const cycles = input.durationSec === 0 ? 0 : Math.ceil(input.durationSec / input.billingCycleSec);
|
||||
const billSec = cycles * input.billingCycleSec;
|
||||
const amount = new Prisma.Decimal(input.cycleRate).mul(cycles).toDecimalPlaces(6);
|
||||
|
||||
return {
|
||||
billSec,
|
||||
cycles,
|
||||
amount
|
||||
};
|
||||
}
|
||||
|
||||
export function isBillableSipCode(sipCode: number): boolean {
|
||||
return sipCode >= 200 && sipCode < 300;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createLogger } from '@lisglosips/observability';
|
||||
import { PrismaClient } from '@lisglosips/database';
|
||||
import {
|
||||
CDR_CONSUMER_GROUP,
|
||||
CDR_STREAM,
|
||||
CdrRetryableError,
|
||||
createRedisClient,
|
||||
ensureCdrConsumerGroup,
|
||||
processCdrBatch,
|
||||
processPendingCdrBatch,
|
||||
type CdrRedisCommands,
|
||||
type CdrStreamHandler
|
||||
} from '@lisglosips/redis';
|
||||
import { CdrRatingService, PrismaCdrRatingStore } from './rating.js';
|
||||
|
||||
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-cdr';
|
||||
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
|
||||
|
||||
const redisUrl = process.env.REDIS_URL;
|
||||
const consumer = process.env.CDR_CONSUMER_NAME ?? `${serviceName}-${process.pid}`;
|
||||
const blockMs = Number.parseInt(process.env.CDR_BLOCK_MS ?? '5000', 10);
|
||||
const batchSize = Number.parseInt(process.env.CDR_BATCH_SIZE ?? '50', 10);
|
||||
const pendingIdleMs = Number.parseInt(process.env.CDR_PENDING_IDLE_MS ?? '60000', 10);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!redisUrl) {
|
||||
throw new Error('REDIS_URL is required for worker-cdr');
|
||||
}
|
||||
|
||||
const redis = createRedisClient(redisUrl);
|
||||
const prisma = new PrismaClient();
|
||||
const ratingService = new CdrRatingService(new PrismaCdrRatingStore(prisma));
|
||||
const handler: CdrStreamHandler = async (event, redisId) => {
|
||||
let result;
|
||||
try {
|
||||
result = await ratingService.rate(event);
|
||||
} catch (error) {
|
||||
if (isTransientDatabaseError(error)) {
|
||||
throw new CdrRetryableError(error instanceof Error ? error.message : 'Transient database error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
redisId,
|
||||
eventId: event.event_id,
|
||||
callId: event.call_id,
|
||||
nodeId: event.node_id,
|
||||
sipCode: event.sipCode,
|
||||
configVersion: event.config_version,
|
||||
rating: result
|
||||
},
|
||||
'CDR stream event rated'
|
||||
);
|
||||
return result.outcome === 'duplicate' ? 'duplicate' : 'processed';
|
||||
};
|
||||
|
||||
await redis.connect();
|
||||
await prisma.$connect();
|
||||
const cdrRedis = redis as unknown as CdrRedisCommands;
|
||||
await ensureCdrConsumerGroup(cdrRedis);
|
||||
logger.info({ stream: CDR_STREAM, group: CDR_CONSUMER_GROUP, consumer }, 'CDR worker started');
|
||||
|
||||
while (!shuttingDown) {
|
||||
const pending = await processPendingCdrBatch(cdrRedis, handler, {
|
||||
consumer,
|
||||
count: batchSize,
|
||||
minIdleMs: pendingIdleMs
|
||||
});
|
||||
if (pending.processed || pending.duplicates || pending.deadlettered) {
|
||||
logger.info(pending, 'CDR pending batch processed');
|
||||
}
|
||||
|
||||
const fresh = await processCdrBatch(cdrRedis, handler, {
|
||||
consumer,
|
||||
count: batchSize,
|
||||
blockMs
|
||||
});
|
||||
if (fresh.processed || fresh.duplicates || fresh.deadlettered) {
|
||||
logger.info(fresh, 'CDR batch processed');
|
||||
}
|
||||
}
|
||||
|
||||
redis.disconnect();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('CDR worker stopping');
|
||||
shuttingDown = true;
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CDR worker stopping');
|
||||
shuttingDown = true;
|
||||
});
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
logger.error({ error }, 'CDR worker failed');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function isTransientDatabaseError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes("Can't reach database server") ||
|
||||
message.includes('Timed out fetching a new connection') ||
|
||||
message.includes('Connection terminated') ||
|
||||
message.includes('ECONNREFUSED')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
|
||||
|
||||
import { calculateCycleCharge } from './billing.js';
|
||||
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, type CreateCustomerChargeInput, type CreateRatedInput } from './rating.js';
|
||||
|
||||
class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||
rawByEventId = new Map<string, { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED' }>();
|
||||
ratedByRawId = new Map<string, { id: string; rawCdrId: string; customerFee: Prisma.Decimal }>();
|
||||
vendorGateway = { id: 'vgw_1', vendorId: 'ven_1', billingCycleSec: 6, cycleRate: new Prisma.Decimal('0.012000') };
|
||||
customer = { id: 'cus_1', balance: new Prisma.Decimal('10.000000') };
|
||||
charges: CreateCustomerChargeInput[] = [];
|
||||
|
||||
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
|
||||
return operation(this);
|
||||
}
|
||||
|
||||
async findRawByEventId(eventId: string) {
|
||||
return this.rawByEventId.get(eventId) ?? null;
|
||||
}
|
||||
|
||||
async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string) {
|
||||
const raw = { id: rawCdrId, eventId: event.event_id, ratingStatus: 'UNRATED' as const };
|
||||
this.rawByEventId.set(event.event_id, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async findRatedByRawCdrId(rawCdrId: string) {
|
||||
return this.ratedByRawId.get(rawCdrId) ?? null;
|
||||
}
|
||||
|
||||
async findVendorGateway() {
|
||||
return this.vendorGateway;
|
||||
}
|
||||
|
||||
async lockCustomer() {
|
||||
return this.customer;
|
||||
}
|
||||
|
||||
async createRated(input: CreateRatedInput) {
|
||||
const rated = { id: input.id, rawCdrId: input.rawCdrId, customerFee: input.customerFee };
|
||||
this.ratedByRawId.set(input.rawCdrId, rated);
|
||||
return rated;
|
||||
}
|
||||
|
||||
async markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void> {
|
||||
for (const [eventId, raw] of this.rawByEventId) {
|
||||
if (raw.id === rawCdrId) {
|
||||
this.rawByEventId.set(eventId, { ...raw, ratingStatus: status });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
|
||||
this.charges.push(input);
|
||||
}
|
||||
|
||||
async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||
this.customer.balance = balance;
|
||||
}
|
||||
}
|
||||
|
||||
function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEvent {
|
||||
return {
|
||||
schema_version: '1',
|
||||
event_id: 'evt_1',
|
||||
idempotency_key: 'call_1:2026-06-21T00:00:30.000Z',
|
||||
call_id: 'call_1',
|
||||
node_id: 'a1',
|
||||
opensips_instance: 'opensips-a1',
|
||||
ingress_a_ip: '100.90.90.90',
|
||||
rtpengine_node: 'a1',
|
||||
customer_id: 'cus_1',
|
||||
customer_gateway_id: 'cgw_1',
|
||||
customer_gateway_policy_id: 'cgp_1',
|
||||
source_ip: '100.93.185.30',
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
vendor_id: 'ven_1',
|
||||
vendor_gateway_id: 'vgw_1',
|
||||
line_group_id: 'lg_1',
|
||||
started_at: '2026-06-21T00:00:00.000Z',
|
||||
answered_at: '2026-06-21T00:00:02.000Z',
|
||||
ended_at: '2026-06-21T00:00:30.000Z',
|
||||
duration_sec: '28',
|
||||
sip_code: '200',
|
||||
hangup_reason: 'NORMAL_CLEARING',
|
||||
recording_key: '',
|
||||
config_version: '1',
|
||||
created_at: '2026-06-21T00:00:31.000Z',
|
||||
durationSeconds: 28,
|
||||
sipCode: 200,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('S23 CDR rating', () => {
|
||||
it('calculates Decimal cycle charges with ceiling billing seconds', () => {
|
||||
const charge = calculateCycleCharge({ durationSec: 28, billingCycleSec: 6, cycleRate: '0.012000' });
|
||||
|
||||
expect(charge.cycles).toBe(5);
|
||||
expect(charge.billSec).toBe(30);
|
||||
expect(charge.amount.toFixed(6)).toBe('0.060000');
|
||||
});
|
||||
|
||||
it('rates a successful CDR and deducts customer balance once', async () => {
|
||||
const store = new MemoryStore();
|
||||
const service = new CdrRatingService(store);
|
||||
|
||||
const result = await service.rate(event());
|
||||
const duplicate = await service.rate(event());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'rated',
|
||||
customerFee: '0.060000',
|
||||
vendorCost: '0.060000',
|
||||
grossProfit: '0.000000',
|
||||
billSec: 30
|
||||
});
|
||||
expect(duplicate.outcome).toBe('duplicate');
|
||||
expect(store.customer.balance.toFixed(6)).toBe('9.940000');
|
||||
expect(store.charges).toHaveLength(1);
|
||||
expect(store.charges[0]?.amount.toFixed(6)).toBe('-0.060000');
|
||||
});
|
||||
|
||||
it('skips failed or zero-duration CDRs without balance changes', async () => {
|
||||
const store = new MemoryStore();
|
||||
const service = new CdrRatingService(store);
|
||||
|
||||
const result = await service.rate(event({ event_id: 'evt_failed', sip_code: '503', sipCode: 503, duration_sec: '0', durationSeconds: 0 }));
|
||||
|
||||
expect(result.outcome).toBe('skipped');
|
||||
expect(result.customerFee).toBe('0.000000');
|
||||
expect(store.customer.balance.toFixed(6)).toBe('10.000000');
|
||||
expect(store.charges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -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