430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
|
|
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||
|
|
import { Logger } from 'nestjs-pino';
|
||
|
|
import { FreebititService } from '../vendors/freebit/freebit.service';
|
||
|
|
import { MappingsService } from '../mappings/mappings.service';
|
||
|
|
import { SubscriptionsService } from './subscriptions.service';
|
||
|
|
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
||
|
|
import { getErrorMessage } from '../common/utils/error.util';
|
||
|
|
|
||
|
|
export interface SimTopUpRequest {
|
||
|
|
quotaMb: number;
|
||
|
|
campaignCode?: string;
|
||
|
|
expiryDate?: string; // YYYYMMDD
|
||
|
|
scheduledAt?: string; // YYYYMMDD or YYYY-MM-DD HH:MM:SS
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SimPlanChangeRequest {
|
||
|
|
newPlanCode: string;
|
||
|
|
assignGlobalIp?: boolean;
|
||
|
|
scheduledAt?: string; // YYYYMMDD
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SimCancelRequest {
|
||
|
|
scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SimTopUpHistoryRequest {
|
||
|
|
fromDate: string; // YYYYMMDD
|
||
|
|
toDate: string; // YYYYMMDD
|
||
|
|
}
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class SimManagementService {
|
||
|
|
constructor(
|
||
|
|
private readonly freebititService: FreebititService,
|
||
|
|
private readonly mappingsService: MappingsService,
|
||
|
|
private readonly subscriptionsService: SubscriptionsService,
|
||
|
|
@Inject(Logger) private readonly logger: Logger,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Debug method to check subscription data for SIM services
|
||
|
|
*/
|
||
|
|
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
|
||
|
|
try {
|
||
|
|
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId);
|
||
|
|
|
||
|
|
return {
|
||
|
|
subscriptionId,
|
||
|
|
productName: subscription.productName,
|
||
|
|
domain: subscription.domain,
|
||
|
|
orderNumber: subscription.orderNumber,
|
||
|
|
customFields: subscription.customFields,
|
||
|
|
isSimService: subscription.productName.toLowerCase().includes('sim') ||
|
||
|
|
subscription.groupName?.toLowerCase().includes('sim'),
|
||
|
|
groupName: subscription.groupName,
|
||
|
|
status: subscription.status,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a subscription is a SIM service
|
||
|
|
*/
|
||
|
|
private async validateSimSubscription(userId: string, subscriptionId: number): Promise<{ account: string }> {
|
||
|
|
try {
|
||
|
|
// Get subscription details to verify it's a SIM service
|
||
|
|
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId);
|
||
|
|
|
||
|
|
// Check if this is a SIM service (you may need to adjust this logic based on your product naming)
|
||
|
|
const isSimService = subscription.productName.toLowerCase().includes('sim') ||
|
||
|
|
subscription.groupName?.toLowerCase().includes('sim');
|
||
|
|
|
||
|
|
if (!isSimService) {
|
||
|
|
throw new BadRequestException('This subscription is not a SIM service');
|
||
|
|
}
|
||
|
|
|
||
|
|
// For SIM services, the account identifier (phone number) can be stored in multiple places
|
||
|
|
let account = '';
|
||
|
|
|
||
|
|
// 1. Try domain field first
|
||
|
|
if (subscription.domain && subscription.domain.trim()) {
|
||
|
|
account = subscription.domain.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. If no domain, check custom fields for phone number/MSISDN
|
||
|
|
if (!account && subscription.customFields) {
|
||
|
|
const phoneFields = ['phone', 'msisdn', 'phonenumber', 'phone_number', 'mobile', 'sim_phone'];
|
||
|
|
for (const fieldName of phoneFields) {
|
||
|
|
if (subscription.customFields[fieldName]) {
|
||
|
|
account = subscription.customFields[fieldName];
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. If still no account, check if subscription ID looks like a phone number
|
||
|
|
if (!account && subscription.orderNumber) {
|
||
|
|
const orderNum = subscription.orderNumber.toString();
|
||
|
|
if (/^\d{10,11}$/.test(orderNum)) {
|
||
|
|
account = orderNum;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Final fallback - for testing, use a dummy phone number based on subscription ID
|
||
|
|
if (!account) {
|
||
|
|
// Generate a test phone number: 080 + last 8 digits of subscription ID
|
||
|
|
const subIdStr = subscriptionId.toString().padStart(8, '0');
|
||
|
|
account = `080${subIdStr.slice(-8)}`;
|
||
|
|
|
||
|
|
this.logger.warn(`No SIM account identifier found for subscription ${subscriptionId}, using generated number: ${account}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
productName: subscription.productName,
|
||
|
|
domain: subscription.domain,
|
||
|
|
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clean up the account format (remove hyphens, spaces, etc.)
|
||
|
|
account = account.replace(/[-\s()]/g, '');
|
||
|
|
|
||
|
|
// Validate phone number format (10-11 digits, optionally starting with +81)
|
||
|
|
const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
|
||
|
|
if (!/^0\d{9,10}$/.test(cleanAccount)) {
|
||
|
|
throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use the cleaned format
|
||
|
|
account = cleanAccount;
|
||
|
|
|
||
|
|
return { account };
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get SIM details for a subscription
|
||
|
|
*/
|
||
|
|
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
const simDetails = await this.freebititService.getSimDetails(account);
|
||
|
|
|
||
|
|
this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
status: simDetails.status,
|
||
|
|
});
|
||
|
|
|
||
|
|
return simDetails;
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to get SIM details for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get SIM data usage for a subscription
|
||
|
|
*/
|
||
|
|
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
const simUsage = await this.freebititService.getSimUsage(account);
|
||
|
|
|
||
|
|
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
todayUsageMb: simUsage.todayUsageMb,
|
||
|
|
});
|
||
|
|
|
||
|
|
return simUsage;
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to get SIM usage for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Top up SIM data quota
|
||
|
|
*/
|
||
|
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
// Validate quota amount
|
||
|
|
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
|
||
|
|
throw new BadRequestException('Quota must be between 1MB and 100GB');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate date formats if provided
|
||
|
|
if (request.expiryDate && !/^\d{8}$/.test(request.expiryDate)) {
|
||
|
|
throw new BadRequestException('Expiry date must be in YYYYMMDD format');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt.replace(/[-:\s]/g, ''))) {
|
||
|
|
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
||
|
|
}
|
||
|
|
|
||
|
|
await this.freebititService.topUpSim(account, request.quotaMb, {
|
||
|
|
campaignCode: request.campaignCode,
|
||
|
|
expiryDate: request.expiryDate,
|
||
|
|
scheduledAt: request.scheduledAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
quotaMb: request.quotaMb,
|
||
|
|
scheduled: !!request.scheduledAt,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
quotaMb: request.quotaMb,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get SIM top-up history
|
||
|
|
*/
|
||
|
|
async getSimTopUpHistory(
|
||
|
|
userId: string,
|
||
|
|
subscriptionId: number,
|
||
|
|
request: SimTopUpHistoryRequest
|
||
|
|
): Promise<SimTopUpHistory> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
// Validate date format
|
||
|
|
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
|
||
|
|
throw new BadRequestException('Dates must be in YYYYMMDD format');
|
||
|
|
}
|
||
|
|
|
||
|
|
const history = await this.freebititService.getSimTopUpHistory(
|
||
|
|
account,
|
||
|
|
request.fromDate,
|
||
|
|
request.toDate
|
||
|
|
);
|
||
|
|
|
||
|
|
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
totalAdditions: history.totalAdditions,
|
||
|
|
});
|
||
|
|
|
||
|
|
return history;
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to get SIM top-up history for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Change SIM plan
|
||
|
|
*/
|
||
|
|
async changeSimPlan(
|
||
|
|
userId: string,
|
||
|
|
subscriptionId: number,
|
||
|
|
request: SimPlanChangeRequest
|
||
|
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
// Validate plan code format
|
||
|
|
if (!request.newPlanCode || request.newPlanCode.length < 3) {
|
||
|
|
throw new BadRequestException('Invalid plan code');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate scheduled date if provided
|
||
|
|
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) {
|
||
|
|
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await this.freebititService.changeSimPlan(account, request.newPlanCode, {
|
||
|
|
assignGlobalIp: request.assignGlobalIp,
|
||
|
|
scheduledAt: request.scheduledAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
newPlanCode: request.newPlanCode,
|
||
|
|
scheduled: !!request.scheduledAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
return result;
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to change SIM plan for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
newPlanCode: request.newPlanCode,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cancel SIM service
|
||
|
|
*/
|
||
|
|
async cancelSim(userId: string, subscriptionId: number, request: SimCancelRequest = {}): Promise<void> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
// Validate scheduled date if provided
|
||
|
|
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) {
|
||
|
|
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
||
|
|
}
|
||
|
|
|
||
|
|
await this.freebititService.cancelSim(account, request.scheduledAt);
|
||
|
|
|
||
|
|
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
scheduled: !!request.scheduledAt,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reissue eSIM profile
|
||
|
|
*/
|
||
|
|
async reissueEsimProfile(userId: string, subscriptionId: number): Promise<void> {
|
||
|
|
try {
|
||
|
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||
|
|
|
||
|
|
// First check if this is actually an eSIM
|
||
|
|
const simDetails = await this.freebititService.getSimDetails(account);
|
||
|
|
if (simDetails.simType !== 'esim') {
|
||
|
|
throw new BadRequestException('This operation is only available for eSIM subscriptions');
|
||
|
|
}
|
||
|
|
|
||
|
|
await this.freebititService.reissueEsimProfile(account);
|
||
|
|
|
||
|
|
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
account,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get comprehensive SIM information (details + usage combined)
|
||
|
|
*/
|
||
|
|
async getSimInfo(userId: string, subscriptionId: number): Promise<{
|
||
|
|
details: SimDetails;
|
||
|
|
usage: SimUsage;
|
||
|
|
}> {
|
||
|
|
try {
|
||
|
|
const [details, usage] = await Promise.all([
|
||
|
|
this.getSimDetails(userId, subscriptionId),
|
||
|
|
this.getSimUsage(userId, subscriptionId),
|
||
|
|
]);
|
||
|
|
|
||
|
|
// If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G)
|
||
|
|
// by subtracting measured usage (today + recentDays) from the plan cap.
|
||
|
|
const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0);
|
||
|
|
const usedMb = normalizeNumber(usage.todayUsageMb) + usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0);
|
||
|
|
|
||
|
|
const planCapMatch = (details.planCode || '').match(/(\d+)\s*G/i);
|
||
|
|
if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) {
|
||
|
|
const capGb = parseInt(planCapMatch[1], 10);
|
||
|
|
if (!isNaN(capGb) && capGb > 0) {
|
||
|
|
const capMb = capGb * 1024;
|
||
|
|
const remainingMb = Math.max(capMb - usedMb, 0);
|
||
|
|
details.remainingQuotaMb = Math.round(remainingMb * 100) / 100;
|
||
|
|
details.remainingQuotaKb = Math.round(details.remainingQuotaMb * 1024);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { details, usage };
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, {
|
||
|
|
error: getErrorMessage(error),
|
||
|
|
userId,
|
||
|
|
subscriptionId,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|