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 CreateRatedInput } from './rating.js'; class MemoryStore implements CdrRatingStore, CdrRatingTransaction { rawByEventId = new Map(); ratedByRawId = new Map(); 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') }; async transaction(operation: (tx: CdrRatingTransaction) => Promise): Promise { 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 { for (const [eventId, raw] of this.rawByEventId) { if (raw.id === rawCdrId) { this.rawByEventId.set(eventId, { ...raw, ratingStatus: status }); } } } async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise { this.customer.balance = balance; } } function event(overrides: Partial = {}): 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', 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', 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 without recharge ledger records', 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'); }); 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'); }); });