Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user