feat: add number library routing and cdr location support
This commit is contained in:
@@ -4,14 +4,13 @@ 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';
|
||||
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, 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);
|
||||
@@ -53,10 +52,6 @@ class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||
}
|
||||
}
|
||||
|
||||
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
|
||||
this.charges.push(input);
|
||||
}
|
||||
|
||||
async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||
this.customer.balance = balance;
|
||||
}
|
||||
@@ -78,6 +73,11 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
|
||||
source_ip: '100.93.185.30',
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
callee_city_code: '340100',
|
||||
callee_city_name: '合肥市',
|
||||
callee_province_name: '安徽省',
|
||||
callee_operator: 'MOBILE',
|
||||
callee_number_type: 'MOBILE',
|
||||
vendor_id: 'ven_1',
|
||||
vendor_gateway_id: 'vgw_1',
|
||||
line_group_id: 'lg_1',
|
||||
@@ -105,7 +105,7 @@ describe('S23 CDR rating', () => {
|
||||
expect(charge.amount.toFixed(6)).toBe('0.060000');
|
||||
});
|
||||
|
||||
it('rates a successful CDR and deducts customer balance once', async () => {
|
||||
it('rates a successful CDR and deducts customer balance once without recharge ledger records', async () => {
|
||||
const store = new MemoryStore();
|
||||
const service = new CdrRatingService(store);
|
||||
|
||||
@@ -121,8 +121,6 @@ describe('S23 CDR rating', () => {
|
||||
});
|
||||
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 () => {
|
||||
@@ -134,6 +132,5 @@ describe('S23 CDR rating', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface CdrRatingTransaction {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -67,16 +66,6 @@ export interface CreateRatedInput {
|
||||
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) {}
|
||||
|
||||
@@ -144,15 +133,6 @@ export class CdrRatingService {
|
||||
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');
|
||||
|
||||
@@ -202,6 +182,11 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
sourceIp: emptyToNull(event.source_ip),
|
||||
caller: event.caller || 'unknown',
|
||||
callee: event.callee || 'unknown',
|
||||
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: nullableId(event.vendor_id),
|
||||
vendorGatewayId: nullableId(event.vendor_gateway_id),
|
||||
lineGroupId: nullableId(event.line_group_id),
|
||||
@@ -253,22 +238,6 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -326,7 +295,7 @@ function parseOptionalInt(value: string): number | null {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
return Number.isInteger(parsed) && parsed >= -2147483648 && parsed <= 2147483647 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalCdrDate(value: string): Date | null {
|
||||
@@ -361,6 +330,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
source_ip: event.source_ip,
|
||||
caller: event.caller,
|
||||
callee: event.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,
|
||||
@@ -378,3 +352,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user