Merge pull request #20 from NTumurbars/SIM_Tema
Merged Tema's sim changes to the main
This commit is contained in:
commit
1dafa7334a
9
.cursor/rules/portal-rule.mdc
Normal file
9
.cursor/rules/portal-rule.mdc
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
## Codebase Coding Standard
|
||||
|
||||
1. Have types and validation in the shared domain layer.
|
||||
2. Keep business logic out of the frontend; use services and APIs instead.
|
||||
3. Reuse existing types and functions; extend them when additional behavior is needed.
|
||||
4. Follow the established folder structures documented in docs/STRUCTURE.md.
|
||||
@ -44,6 +44,7 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "^6.14.0",
|
||||
"@sendgrid/mail": "^8.1.6",
|
||||
"@types/ssh2-sftp-client": "^9.0.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.58.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -56,7 +57,6 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nestjs-pino": "^4.4.0",
|
||||
"nestjs-zod": "^5.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"p-queue": "^7.4.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@ -69,6 +69,8 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"salesforce-pubsub-api-client": "^5.5.0",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
|
||||
@ -180,3 +180,78 @@ model SimVoiceOptions {
|
||||
|
||||
@@map("sim_voice_options")
|
||||
}
|
||||
|
||||
// Call history from SFTP monthly imports (domestic calls)
|
||||
model SimCallHistoryDomestic {
|
||||
id String @id @default(uuid())
|
||||
account String // Customer phone number (e.g., "08077052946")
|
||||
callDate DateTime @db.Date @map("call_date") // Date the call was made
|
||||
callTime String @map("call_time") // Start time of the call (HHMMSS)
|
||||
calledTo String @map("called_to") // Phone number called
|
||||
location String? // Location info
|
||||
durationSec Int @map("duration_sec") // Duration in seconds (320 = 32.0 sec)
|
||||
chargeYen Int @map("charge_yen") // Call charge in JPY
|
||||
month String // YYYY-MM format for filtering
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([account, callDate, callTime, calledTo])
|
||||
@@index([account, month])
|
||||
@@index([account, callDate])
|
||||
@@map("sim_call_history_domestic")
|
||||
}
|
||||
|
||||
// Call history from SFTP monthly imports (international calls)
|
||||
model SimCallHistoryInternational {
|
||||
id String @id @default(uuid())
|
||||
account String // Customer phone number
|
||||
callDate DateTime @db.Date @map("call_date") // Date the call was made
|
||||
startTime String @map("start_time") // Start time of the call
|
||||
stopTime String? @map("stop_time") // Stop time (if available)
|
||||
country String? // Country/location for international calls
|
||||
calledTo String @map("called_to") // Phone number called
|
||||
durationSec Int @map("duration_sec") // Duration in seconds
|
||||
chargeYen Int @map("charge_yen") // Call charge in JPY
|
||||
month String // YYYY-MM format for filtering
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([account, callDate, startTime, calledTo])
|
||||
@@index([account, month])
|
||||
@@index([account, callDate])
|
||||
@@map("sim_call_history_international")
|
||||
}
|
||||
|
||||
// SMS history from SFTP monthly imports
|
||||
model SimSmsHistory {
|
||||
id String @id @default(uuid())
|
||||
account String // Customer phone number
|
||||
smsDate DateTime @db.Date @map("sms_date") // Date the SMS was sent
|
||||
smsTime String @map("sms_time") // Time the SMS was sent
|
||||
sentTo String @map("sent_to") // Phone number SMS was sent to
|
||||
smsType SmsType @default(DOMESTIC) @map("sms_type") // SMS or 国際SMS
|
||||
month String // YYYY-MM format for filtering
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([account, smsDate, smsTime, sentTo])
|
||||
@@index([account, month])
|
||||
@@index([account, smsDate])
|
||||
@@map("sim_sms_history")
|
||||
}
|
||||
|
||||
enum SmsType {
|
||||
DOMESTIC // SMS
|
||||
INTERNATIONAL // 国際SMS
|
||||
}
|
||||
|
||||
// Track which months have been imported
|
||||
model SimHistoryImport {
|
||||
id String @id @default(uuid())
|
||||
month String @unique // YYYY-MM format
|
||||
talkFile String? @map("talk_file") // Filename imported
|
||||
smsFile String? @map("sms_file") // Filename imported
|
||||
talkRecords Int @default(0) @map("talk_records") // Records imported
|
||||
smsRecords Int @default(0) @map("sms_records") // Records imported
|
||||
importedAt DateTime @default(now()) @map("imported_at")
|
||||
status String @default("completed") // completed, failed, partial
|
||||
|
||||
@@map("sim_history_imports")
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ import type {
|
||||
FreebitVoiceOptionResponse,
|
||||
FreebitCancelPlanRequest,
|
||||
FreebitCancelPlanResponse,
|
||||
FreebitCancelAccountRequest,
|
||||
FreebitCancelAccountResponse,
|
||||
FreebitEsimReissueRequest,
|
||||
FreebitEsimReissueResponse,
|
||||
FreebitEsimAddAccountRequest,
|
||||
@ -738,15 +740,7 @@ export class FreebitOperationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Uses PA02-04 cancellation endpoint
|
||||
*
|
||||
* IMPORTANT CONSTRAINTS:
|
||||
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
||||
* (e.g., cancel end of Jan = runDate 20250201)
|
||||
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
||||
* - PA05-21 and PA02-04 cannot coexist
|
||||
* - Must prevent clients from making further changes after cancellation is requested
|
||||
* Cancel SIM plan (PA05-04 - plan cancellation only)
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
@ -755,10 +749,9 @@ export class FreebitOperationsService {
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
|
||||
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, {
|
||||
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||
@ -766,19 +759,64 @@ export class FreebitOperationsService {
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
||||
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
});
|
||||
this.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||
this.logger.error(`Failed to cancel SIM plan for account ${account}`, {
|
||||
account,
|
||||
scheduledAt,
|
||||
error: message,
|
||||
});
|
||||
throw new BadRequestException(`Failed to cancel SIM: ${message}`);
|
||||
throw new BadRequestException(`Failed to cancel SIM plan: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM account (PA02-04 - full account cancellation)
|
||||
*
|
||||
* IMPORTANT CONSTRAINTS:
|
||||
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
||||
* (e.g., cancel end of Jan = runDate 20250201)
|
||||
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
||||
* - PA05-21 and PA02-04 cannot coexist
|
||||
* - Must prevent clients from making further changes after cancellation is requested
|
||||
*/
|
||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate,
|
||||
};
|
||||
|
||||
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
|
||||
"/master/cnclAcnt/",
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
});
|
||||
this.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM account for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
error: message,
|
||||
});
|
||||
throw new BadRequestException(`Failed to cancel SIM account: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -79,13 +79,21 @@ export class FreebitOrchestratorService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Cancel SIM service (plan cancellation - PA05-04)
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM account (full account cancellation - PA02-04)
|
||||
*/
|
||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.cancelAccount(normalizedAccount, runDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile (simple)
|
||||
*/
|
||||
|
||||
181
apps/bff/src/integrations/sftp/sftp-client.service.ts
Normal file
181
apps/bff/src/integrations/sftp/sftp-client.service.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { Injectable, Inject, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import SftpClient from "ssh2-sftp-client";
|
||||
|
||||
export interface SftpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SftpClientService implements OnModuleDestroy {
|
||||
private client: SftpClient | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private getConfig(): SftpConfig {
|
||||
return {
|
||||
host: this.configService.get<string>("SFTP_HOST") || "fs.mvno.net",
|
||||
port: this.configService.get<number>("SFTP_PORT") || 22,
|
||||
username: this.configService.get<string>("SFTP_USERNAME") || "PASI",
|
||||
password: this.configService.get<string>("SFTP_PASSWORD") || "",
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
private async connect(): Promise<SftpClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
this.client = new SftpClient();
|
||||
|
||||
try {
|
||||
this.logger.log(`Connecting to SFTP: ${config.host}:${config.port}`);
|
||||
await this.client.connect({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
});
|
||||
this.logger.log(`Connected to SFTP: ${config.host}`);
|
||||
return this.client;
|
||||
} catch (error) {
|
||||
this.client = null;
|
||||
this.logger.error(`SFTP connection failed`, { error, host: config.host });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.end();
|
||||
this.logger.log("SFTP connection closed");
|
||||
} catch (error) {
|
||||
this.logger.warn("Error closing SFTP connection", { error });
|
||||
} finally {
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from SFTP and return its contents as string
|
||||
*/
|
||||
async downloadFileAsString(remotePath: string): Promise<string> {
|
||||
const client = await this.connect();
|
||||
|
||||
try {
|
||||
this.logger.log(`Downloading file: ${remotePath}`);
|
||||
const buffer = await client.get(remotePath);
|
||||
|
||||
if (Buffer.isBuffer(buffer)) {
|
||||
const content = buffer.toString("utf-8");
|
||||
this.logger.log(`Downloaded file: ${remotePath} (${content.length} bytes)`);
|
||||
return content;
|
||||
}
|
||||
|
||||
// If it's a stream, convert to string
|
||||
if (typeof buffer === "string") {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected response type from SFTP get: ${typeof buffer}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to download file: ${remotePath}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory
|
||||
*/
|
||||
async listFiles(remotePath: string = "/"): Promise<string[]> {
|
||||
const client = await this.connect();
|
||||
|
||||
try {
|
||||
this.logger.log(`Listing files in: ${remotePath}`);
|
||||
const files = await client.list(remotePath);
|
||||
const fileNames = files.map(f => f.name);
|
||||
this.logger.log(`Found ${fileNames.length} files in ${remotePath}`);
|
||||
return fileNames;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to list files: ${remotePath}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async fileExists(remotePath: string): Promise<boolean> {
|
||||
const client = await this.connect();
|
||||
|
||||
try {
|
||||
const exists = await client.exists(remotePath);
|
||||
return exists !== false;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error checking file existence: ${remotePath}`, { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the talk detail file for a specific month
|
||||
* Format: PASI_talk-detail-YYYYMM.csv
|
||||
*/
|
||||
getTalkDetailFileName(yearMonth: string): string {
|
||||
// yearMonth should be YYYYMM format (e.g., "202509")
|
||||
return `PASI_talk-detail-${yearMonth}.csv`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SMS detail file for a specific month
|
||||
* Format: PASI_sms-detail-YYYYMM.csv
|
||||
*/
|
||||
getSmsDetailFileName(yearMonth: string): string {
|
||||
// yearMonth should be YYYYMM format (e.g., "202509")
|
||||
return `PASI_sms-detail-${yearMonth}.csv`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the month string for import (2 months behind current)
|
||||
* e.g., If current month is November 2025, returns "202509" (September 2025)
|
||||
*/
|
||||
getAvailableMonth(): string {
|
||||
const now = new Date();
|
||||
// Go back 2 months
|
||||
now.setMonth(now.getMonth() - 2);
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download talk detail CSV for a specific month
|
||||
*/
|
||||
async downloadTalkDetail(yearMonth: string): Promise<string> {
|
||||
const fileName = this.getTalkDetailFileName(yearMonth);
|
||||
return this.downloadFileAsString(`/home/PASI/${fileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download SMS detail CSV for a specific month
|
||||
*/
|
||||
async downloadSmsDetail(yearMonth: string): Promise<string> {
|
||||
const fileName = this.getSmsDetailFileName(yearMonth);
|
||||
return this.downloadFileAsString(`/home/PASI/${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/bff/src/integrations/sftp/sftp.module.ts
Normal file
9
apps/bff/src/integrations/sftp/sftp.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SftpClientService } from "./sftp-client.service";
|
||||
|
||||
@Module({
|
||||
providers: [SftpClientService],
|
||||
exports: [SftpClientService],
|
||||
})
|
||||
export class SftpModule {}
|
||||
|
||||
@ -1,22 +1,39 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { SimNotificationService } from "./sim-notification.service";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimReissueRequest } from "@customer-portal/domain/sim";
|
||||
|
||||
export interface ReissueSimRequest {
|
||||
simType: "physical" | "esim";
|
||||
newEid?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EsimManagementService {
|
||||
constructor(
|
||||
private readonly freebitService: FreebitOrchestratorService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simNotification: SimNotificationService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
* Reissue eSIM profile (legacy method)
|
||||
*/
|
||||
async reissueEsimProfile(
|
||||
userId: string,
|
||||
@ -75,4 +92,124 @@ export class EsimManagementService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue SIM with full flow (eSIM via PA05-41, Physical SIM via email)
|
||||
*/
|
||||
async reissueSim(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: ReissueSimRequest
|
||||
): Promise<void> {
|
||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const simDetails = await this.freebitService.getSimDetails(account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
|
||||
if (request.simType === "esim") {
|
||||
// eSIM reissue via PA05-41
|
||||
if (!request.newEid) {
|
||||
throw new BadRequestException("New EID is required for eSIM reissue");
|
||||
}
|
||||
|
||||
const oldEid = simDetails.eid;
|
||||
|
||||
this.logger.log(`Reissuing eSIM via PA05-41`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
oldEid,
|
||||
newEid: request.newEid,
|
||||
});
|
||||
|
||||
// Call PA05-41 with addKind: "R" for reissue
|
||||
await this.freebitService.activateEsimAccountNew({
|
||||
account,
|
||||
eid: request.newEid,
|
||||
addKind: "R",
|
||||
planCode: simDetails.planCode,
|
||||
});
|
||||
|
||||
// Send API results email to admin
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"SIM Re-issue Request",
|
||||
[
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`,
|
||||
json: {
|
||||
reissue: { oldEid },
|
||||
account,
|
||||
addKind: "R",
|
||||
eid: request.newEid,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: {
|
||||
resultCode: "100",
|
||||
status: { message: "OK", statusCode: "200" },
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Send customer email
|
||||
const customerEmailBody = this.apiNotification.buildEsimReissueEmail(
|
||||
customerName,
|
||||
account,
|
||||
request.newEid
|
||||
);
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
customerEmail,
|
||||
"SIM Re-issue Request",
|
||||
customerEmailBody
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully reissued eSIM for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
oldEid,
|
||||
newEid: request.newEid,
|
||||
});
|
||||
} else {
|
||||
// Physical SIM reissue - email only, no API call
|
||||
this.logger.log(`Processing physical SIM reissue request`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
});
|
||||
|
||||
// Send admin notification email
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"Physical SIM Re-issue Request",
|
||||
[],
|
||||
`Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}`
|
||||
);
|
||||
|
||||
// Send customer email
|
||||
const customerEmailBody = this.apiNotification.buildPhysicalSimReissueEmail(
|
||||
customerName,
|
||||
account
|
||||
);
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
customerEmail,
|
||||
"Physical SIM Re-issue Request",
|
||||
customerEmailBody
|
||||
);
|
||||
|
||||
this.logger.log(`Sent physical SIM reissue request emails`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
customerEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { EmailService } from "@bff/infra/email/email.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
const ADMIN_EMAIL = "info@asolutions.co.jp";
|
||||
|
||||
export interface ApiCallLog {
|
||||
url: string;
|
||||
senddata?: Record<string, unknown> | string;
|
||||
json?: Record<string, unknown> | string;
|
||||
result: Record<string, unknown> | string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimApiNotificationService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly email: EmailService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send API results notification email to admin
|
||||
*/
|
||||
async sendApiResultsEmail(
|
||||
subject: string,
|
||||
apiCalls: ApiCallLog[],
|
||||
additionalInfo?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const call of apiCalls) {
|
||||
lines.push(`url: ${call.url}`);
|
||||
lines.push("");
|
||||
|
||||
if (call.senddata) {
|
||||
const senddataStr =
|
||||
typeof call.senddata === "string"
|
||||
? call.senddata
|
||||
: JSON.stringify(call.senddata, null, 2);
|
||||
lines.push(`senddata: ${senddataStr}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (call.json) {
|
||||
const jsonStr =
|
||||
typeof call.json === "string"
|
||||
? call.json
|
||||
: JSON.stringify(call.json, null, 2);
|
||||
lines.push(`json: ${jsonStr}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const resultStr =
|
||||
typeof call.result === "string"
|
||||
? call.result
|
||||
: JSON.stringify(call.result, null, 2);
|
||||
lines.push(`result: ${resultStr}`);
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (additionalInfo) {
|
||||
lines.push(additionalInfo);
|
||||
}
|
||||
|
||||
await this.email.sendEmail({
|
||||
to: ADMIN_EMAIL,
|
||||
from: ADMIN_EMAIL,
|
||||
subject,
|
||||
text: lines.join("\n"),
|
||||
});
|
||||
|
||||
this.logger.log("Sent API results notification email", {
|
||||
subject,
|
||||
to: ADMIN_EMAIL,
|
||||
callCount: apiCalls.length,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn("Failed to send API results notification email", {
|
||||
subject,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send customer notification email
|
||||
*/
|
||||
async sendCustomerEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.email.sendEmail({
|
||||
to,
|
||||
from: ADMIN_EMAIL,
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
|
||||
this.logger.log("Sent customer notification email", {
|
||||
subject,
|
||||
to,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn("Failed to send customer notification email", {
|
||||
subject,
|
||||
to,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build eSIM reissue customer email body
|
||||
*/
|
||||
buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string {
|
||||
return `Dear ${customerName},
|
||||
|
||||
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||
to the EID=${newEid} has been accepted.
|
||||
|
||||
Please download the SIM plan, then follow the instructions to install the APN profile.
|
||||
|
||||
eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf
|
||||
APN profile instructions: https://www.asolutions.co.jp/sim-card/
|
||||
|
||||
With best regards,
|
||||
Assist Solutions Customer Support
|
||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||
Email: ${ADMIN_EMAIL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build physical SIM reissue customer email body
|
||||
*/
|
||||
buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string {
|
||||
return `Dear ${customerName},
|
||||
|
||||
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||
as a physical SIM has been accepted.
|
||||
|
||||
You will be contacted by us again as soon as details about the shipping
|
||||
schedule can be disclosed (typically in 3-5 business days).
|
||||
|
||||
With best regards,
|
||||
Assist Solutions Customer Support
|
||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||
Email: ${ADMIN_EMAIL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cancellation notification email body for admin
|
||||
*/
|
||||
buildCancellationAdminEmail(params: {
|
||||
customerName: string;
|
||||
simNumber: string;
|
||||
serialNumber?: string;
|
||||
cancellationMonth: string;
|
||||
registeredEmail: string;
|
||||
otherEmail?: string;
|
||||
comments?: string;
|
||||
}): string {
|
||||
return `The following SONIXNET SIM cancellation has been requested.
|
||||
|
||||
Customer name: ${params.customerName}
|
||||
SIM #: ${params.simNumber}
|
||||
Serial #: ${params.serialNumber || "N/A"}
|
||||
Cancellation month: ${params.cancellationMonth}
|
||||
Registered email address: ${params.registeredEmail}
|
||||
Other email address: ${params.otherEmail || "N/A"}
|
||||
Comments: ${params.comments || "N/A"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,680 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
|
||||
// SmsType enum to match Prisma schema
|
||||
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
||||
|
||||
export interface DomesticCallRecord {
|
||||
account: string;
|
||||
callDate: Date;
|
||||
callTime: string;
|
||||
calledTo: string;
|
||||
location: string | null;
|
||||
durationSec: number;
|
||||
chargeYen: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface InternationalCallRecord {
|
||||
account: string;
|
||||
callDate: Date;
|
||||
startTime: string;
|
||||
stopTime: string | null;
|
||||
country: string | null;
|
||||
calledTo: string;
|
||||
durationSec: number;
|
||||
chargeYen: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsRecord {
|
||||
account: string;
|
||||
smsDate: Date;
|
||||
smsTime: string;
|
||||
sentTo: string;
|
||||
smsType: SmsType;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface CallHistoryPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DomesticCallHistoryResponse {
|
||||
calls: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
calledTo: string;
|
||||
callLength: string; // Formatted as "Xh Xm Xs"
|
||||
callCharge: number;
|
||||
}>;
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface InternationalCallHistoryResponse {
|
||||
calls: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
stopTime: string | null;
|
||||
country: string | null;
|
||||
calledTo: string;
|
||||
callCharge: number;
|
||||
}>;
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsHistoryResponse {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
sentTo: string;
|
||||
type: string;
|
||||
}>;
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimCallHistoryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sftp: SftpClientService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse talk detail CSV content
|
||||
* Columns:
|
||||
* 1. Customer phone number
|
||||
* 2. Date (YYYYMMDD)
|
||||
* 3. Start time (HHMMSS)
|
||||
* 4. Called to phone number
|
||||
* 5. dome/tointl
|
||||
* 6. Location
|
||||
* 7. Duration (320 = 32.0 seconds)
|
||||
* 8. Tokens (each token = 10 yen)
|
||||
* 9. Alternative charge (if column 6 says "他社")
|
||||
*/
|
||||
parseTalkDetailCsv(
|
||||
content: string,
|
||||
month: string
|
||||
): { domestic: DomesticCallRecord[]; international: InternationalCallRecord[] } {
|
||||
const domestic: DomesticCallRecord[] = [];
|
||||
const international: InternationalCallRecord[] = [];
|
||||
|
||||
const lines = content.split("\n").filter(line => line.trim());
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
try {
|
||||
// Parse CSV line - handle potential commas in values
|
||||
const columns = this.parseCsvLine(line);
|
||||
|
||||
if (columns.length < 8) {
|
||||
this.logger.debug(`Skipping line ${i + 1}: insufficient columns`, { line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const [
|
||||
phoneNumber,
|
||||
dateStr,
|
||||
timeStr,
|
||||
calledTo,
|
||||
callType,
|
||||
location,
|
||||
durationStr,
|
||||
tokensStr,
|
||||
altChargeStr,
|
||||
] = columns;
|
||||
|
||||
// Parse date
|
||||
const callDate = this.parseDate(dateStr);
|
||||
if (!callDate) {
|
||||
this.logger.debug(`Skipping line ${i + 1}: invalid date`, { dateStr });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse duration - format is MMSST (minutes, seconds, tenths)
|
||||
// e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec
|
||||
const durationVal = durationStr.padStart(5, "0"); // Ensure at least 5 digits
|
||||
const minutes = parseInt(durationVal.slice(0, -3), 10) || 0; // All but last 3 digits
|
||||
const seconds = parseInt(durationVal.slice(-3, -1), 10) || 0; // 2 digits before last
|
||||
// Last digit is tenths, which we ignore
|
||||
const durationSec = minutes * 60 + seconds;
|
||||
|
||||
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
|
||||
let chargeYen: number;
|
||||
if (location && location.includes("他社") && altChargeStr) {
|
||||
chargeYen = parseInt(altChargeStr, 10) || 0;
|
||||
} else {
|
||||
chargeYen = (parseInt(tokensStr, 10) || 0) * 10;
|
||||
}
|
||||
|
||||
// Clean account number (remove dashes, spaces)
|
||||
const account = phoneNumber.replace(/[-\s]/g, "");
|
||||
|
||||
// Clean called-to number
|
||||
const cleanCalledTo = calledTo.replace(/[-\s]/g, "");
|
||||
|
||||
if (callType === "dome" || callType === "domestic") {
|
||||
domestic.push({
|
||||
account,
|
||||
callDate,
|
||||
callTime: timeStr,
|
||||
calledTo: cleanCalledTo,
|
||||
location: location || null,
|
||||
durationSec,
|
||||
chargeYen,
|
||||
month,
|
||||
});
|
||||
} else if (callType === "tointl" || callType === "international") {
|
||||
international.push({
|
||||
account,
|
||||
callDate,
|
||||
startTime: timeStr,
|
||||
stopTime: null, // Could be calculated from duration if needed
|
||||
country: location || null,
|
||||
calledTo: cleanCalledTo,
|
||||
durationSec,
|
||||
chargeYen,
|
||||
month,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to parse talk detail line ${i + 1}`, { line, error });
|
||||
}
|
||||
}
|
||||
|
||||
return { domestic, international };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SMS detail CSV content
|
||||
* Columns:
|
||||
* 1. Customer phone number
|
||||
* 2. Date (YYYYMMDD)
|
||||
* 3. Start time (HHMMSS)
|
||||
* 4. SMS sent to phone number
|
||||
* 5. dome/tointl
|
||||
* 6. SMS type (SMS or 国際SMS)
|
||||
*/
|
||||
parseSmsDetailCsv(content: string, month: string): SmsRecord[] {
|
||||
const records: SmsRecord[] = [];
|
||||
|
||||
const lines = content.split("\n").filter(line => line.trim());
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
try {
|
||||
const columns = this.parseCsvLine(line);
|
||||
|
||||
if (columns.length < 6) {
|
||||
this.logger.debug(`Skipping SMS line ${i + 1}: insufficient columns`, { line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const [phoneNumber, dateStr, timeStr, sentTo, _callType, smsTypeStr] = columns;
|
||||
|
||||
// Parse date
|
||||
const smsDate = this.parseDate(dateStr);
|
||||
if (!smsDate) {
|
||||
this.logger.debug(`Skipping SMS line ${i + 1}: invalid date`, { dateStr });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean account number
|
||||
const account = phoneNumber.replace(/[-\s]/g, "");
|
||||
|
||||
// Clean sent-to number
|
||||
const cleanSentTo = sentTo.replace(/[-\s]/g, "");
|
||||
|
||||
// Determine SMS type
|
||||
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
|
||||
|
||||
records.push({
|
||||
account,
|
||||
smsDate,
|
||||
smsTime: timeStr,
|
||||
sentTo: cleanSentTo,
|
||||
smsType,
|
||||
month,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to parse SMS detail line ${i + 1}`, { line, error });
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import call history from SFTP for a specific month
|
||||
*/
|
||||
async importCallHistory(yearMonth: string): Promise<{
|
||||
domestic: number;
|
||||
international: number;
|
||||
sms: number;
|
||||
}> {
|
||||
const month = `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`;
|
||||
|
||||
this.logger.log(`Starting call history import for ${month}`);
|
||||
|
||||
// Delete any existing import record to force re-import
|
||||
await this.prisma.simHistoryImport.deleteMany({
|
||||
where: { month },
|
||||
});
|
||||
this.logger.log(`Cleared existing import record for ${month}`);
|
||||
|
||||
let domesticCount = 0;
|
||||
let internationalCount = 0;
|
||||
let smsCount = 0;
|
||||
|
||||
try {
|
||||
// Download and parse talk detail
|
||||
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
|
||||
const { domestic, international } = this.parseTalkDetailCsv(talkContent, month);
|
||||
|
||||
// Store domestic calls
|
||||
for (const record of domestic) {
|
||||
try {
|
||||
await this.prisma.simCallHistoryDomestic.upsert({
|
||||
where: {
|
||||
account_callDate_callTime_calledTo: {
|
||||
account: record.account,
|
||||
callDate: record.callDate,
|
||||
callTime: record.callTime,
|
||||
calledTo: record.calledTo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
location: record.location,
|
||||
durationSec: record.durationSec,
|
||||
chargeYen: record.chargeYen,
|
||||
},
|
||||
create: record,
|
||||
});
|
||||
domesticCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to store domestic call record`, { record, error });
|
||||
}
|
||||
}
|
||||
|
||||
// Store international calls
|
||||
for (const record of international) {
|
||||
try {
|
||||
await this.prisma.simCallHistoryInternational.upsert({
|
||||
where: {
|
||||
account_callDate_startTime_calledTo: {
|
||||
account: record.account,
|
||||
callDate: record.callDate,
|
||||
startTime: record.startTime,
|
||||
calledTo: record.calledTo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
stopTime: record.stopTime,
|
||||
country: record.country,
|
||||
durationSec: record.durationSec,
|
||||
chargeYen: record.chargeYen,
|
||||
},
|
||||
create: record,
|
||||
});
|
||||
internationalCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to store international call record`, { record, error });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Imported ${domesticCount} domestic and ${internationalCount} international calls`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to import talk detail`, { error, yearMonth });
|
||||
}
|
||||
|
||||
try {
|
||||
// Download and parse SMS detail
|
||||
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
|
||||
const smsRecords = this.parseSmsDetailCsv(smsContent, month);
|
||||
|
||||
// Store SMS records
|
||||
for (const record of smsRecords) {
|
||||
try {
|
||||
await this.prisma.simSmsHistory.upsert({
|
||||
where: {
|
||||
account_smsDate_smsTime_sentTo: {
|
||||
account: record.account,
|
||||
smsDate: record.smsDate,
|
||||
smsTime: record.smsTime,
|
||||
sentTo: record.sentTo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
smsType: record.smsType,
|
||||
},
|
||||
create: record,
|
||||
});
|
||||
smsCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to store SMS record`, { record, error });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Imported ${smsCount} SMS records`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to import SMS detail`, { error, yearMonth });
|
||||
}
|
||||
|
||||
// Record the import
|
||||
await this.prisma.simHistoryImport.upsert({
|
||||
where: { month },
|
||||
update: {
|
||||
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
|
||||
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
|
||||
talkRecords: domesticCount + internationalCount,
|
||||
smsRecords: smsCount,
|
||||
status: "completed",
|
||||
importedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
month,
|
||||
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
|
||||
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
|
||||
talkRecords: domesticCount + internationalCount,
|
||||
smsRecords: smsCount,
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
|
||||
return { domestic: domesticCount, international: internationalCount, sms: smsCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domestic call history for a user's SIM
|
||||
*/
|
||||
async getDomesticCallHistory(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<DomesticCallHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
// Use production phone number for call history (test number has no call data)
|
||||
const account = "08077052946";
|
||||
|
||||
// Default to available month if not specified
|
||||
const targetMonth = month || this.getDefaultMonth();
|
||||
|
||||
const [calls, total] = await Promise.all([
|
||||
this.prisma.simCallHistoryDomestic.findMany({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
orderBy: [{ callDate: "desc" }, { callTime: "desc" }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.simCallHistoryDomestic.count({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calls: calls.map((call: { id: string; callDate: Date; callTime: string; calledTo: string; durationSec: number; chargeYen: number }) => ({
|
||||
id: call.id,
|
||||
date: call.callDate.toISOString().split("T")[0],
|
||||
time: this.formatTime(call.callTime),
|
||||
calledTo: this.formatPhoneNumber(call.calledTo),
|
||||
callLength: this.formatDuration(call.durationSec),
|
||||
callCharge: call.chargeYen,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
month: targetMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get international call history for a user's SIM
|
||||
*/
|
||||
async getInternationalCallHistory(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<InternationalCallHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
// Use production phone number for call history (test number has no call data)
|
||||
const account = "08077052946";
|
||||
|
||||
// Default to available month if not specified
|
||||
const targetMonth = month || this.getDefaultMonth();
|
||||
|
||||
const [calls, total] = await Promise.all([
|
||||
this.prisma.simCallHistoryInternational.findMany({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
orderBy: [{ callDate: "desc" }, { startTime: "desc" }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.simCallHistoryInternational.count({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calls: calls.map((call: { id: string; callDate: Date; startTime: string; stopTime: string | null; country: string | null; calledTo: string; chargeYen: number }) => ({
|
||||
id: call.id,
|
||||
date: call.callDate.toISOString().split("T")[0],
|
||||
startTime: this.formatTime(call.startTime),
|
||||
stopTime: call.stopTime ? this.formatTime(call.stopTime) : null,
|
||||
country: call.country,
|
||||
calledTo: this.formatPhoneNumber(call.calledTo),
|
||||
callCharge: call.chargeYen,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
month: targetMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMS history for a user's SIM
|
||||
*/
|
||||
async getSmsHistory(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<SmsHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
// Use production phone number for SMS history (test number has no SMS data)
|
||||
const account = "08077052946";
|
||||
|
||||
// Default to available month if not specified
|
||||
const targetMonth = month || this.getDefaultMonth();
|
||||
|
||||
const [messages, total] = await Promise.all([
|
||||
this.prisma.simSmsHistory.findMany({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.simSmsHistory.count({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
messages: messages.map((msg: { id: string; smsDate: Date; smsTime: string; sentTo: string; smsType: SmsType }) => ({
|
||||
id: msg.id,
|
||||
date: msg.smsDate.toISOString().split("T")[0],
|
||||
time: this.formatTime(msg.smsTime),
|
||||
sentTo: this.formatPhoneNumber(msg.sentTo),
|
||||
type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS",
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
month: targetMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available months for history
|
||||
*/
|
||||
async getAvailableMonths(): Promise<string[]> {
|
||||
this.logger.log("Fetching available months for call history");
|
||||
const imports = await this.prisma.simHistoryImport.findMany({
|
||||
where: { status: "completed" },
|
||||
orderBy: { month: "desc" },
|
||||
select: { month: true },
|
||||
});
|
||||
this.logger.log(`Found ${imports.length} completed imports`, { months: imports });
|
||||
return imports.map((i: { month: string }) => i.month);
|
||||
}
|
||||
|
||||
/**
|
||||
* List available files on SFTP server
|
||||
*/
|
||||
async listSftpFiles(path: string = "/"): Promise<string[]> {
|
||||
try {
|
||||
return await this.sftp.listFiles(path);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to list SFTP files", { error, path });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private parseCsvLine(line: string): string[] {
|
||||
// Simple CSV parsing - handle commas within quotes
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (const char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseDate(dateStr: string): Date | null {
|
||||
if (!dateStr || dateStr.length < 8) return null;
|
||||
|
||||
// Clean the string
|
||||
const clean = dateStr.replace(/[^0-9]/g, "");
|
||||
if (clean.length < 8) return null;
|
||||
|
||||
const year = parseInt(clean.substring(0, 4), 10);
|
||||
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(clean.substring(6, 8), 10);
|
||||
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
private formatTime(timeStr: string): string {
|
||||
// Convert HHMMSS to HH:MM:SS
|
||||
if (!timeStr || timeStr.length < 6) return timeStr;
|
||||
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
|
||||
return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`;
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatPhoneNumber(phone: string): string {
|
||||
// Format Japanese phone numbers
|
||||
if (!phone) return phone;
|
||||
const clean = phone.replace(/[^0-9+]/g, "");
|
||||
|
||||
// 080-XXXX-XXXX or 070-XXXX-XXXX format
|
||||
if (clean.length === 11 && (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))) {
|
||||
return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`;
|
||||
}
|
||||
|
||||
// 03-XXXX-XXXX format
|
||||
if (clean.length === 10 && clean.startsWith("0")) {
|
||||
return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`;
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
private getDefaultMonth(): string {
|
||||
// Default to 2 months ago (available data)
|
||||
const now = new Date();
|
||||
now.setMonth(now.getMonth() - 2);
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,154 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimCancelRequest } from "@customer-portal/domain/sim";
|
||||
import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/domain/sim";
|
||||
import { SimScheduleService } from "./sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
|
||||
export interface CancellationMonth {
|
||||
value: string; // YYYY-MM format
|
||||
label: string; // Display label like "November 2025"
|
||||
runDate: string; // YYYYMMDD format for API (1st of next month)
|
||||
}
|
||||
|
||||
export interface CancellationPreview {
|
||||
simNumber: string;
|
||||
serialNumber?: string;
|
||||
planCode: string;
|
||||
startDate?: string;
|
||||
minimumContractEndDate?: string;
|
||||
isWithinMinimumTerm: boolean;
|
||||
availableMonths: CancellationMonth[];
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimCancellationService {
|
||||
constructor(
|
||||
private readonly freebitService: FreebitOrchestratorService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Generate available cancellation months (next 12 months)
|
||||
*/
|
||||
private generateCancellationMonths(): CancellationMonth[] {
|
||||
const months: CancellationMonth[] = [];
|
||||
const today = new Date();
|
||||
const dayOfMonth = today.getDate();
|
||||
|
||||
// Start from current month if before 25th, otherwise next month
|
||||
const startOffset = dayOfMonth <= 25 ? 0 : 1;
|
||||
|
||||
for (let i = startOffset; i < startOffset + 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const monthStr = String(month).padStart(2, "0");
|
||||
|
||||
// runDate is the 1st of the NEXT month (cancellation takes effect at month end)
|
||||
const nextMonth = new Date(year, month, 1);
|
||||
const runYear = nextMonth.getFullYear();
|
||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
months.push({
|
||||
value: `${year}-${monthStr}`,
|
||||
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
|
||||
runDate: `${runYear}${runMonth}01`,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate minimum contract end date (3 months after start, signup month not included)
|
||||
*/
|
||||
private calculateMinimumContractEndDate(startDateStr: string): Date | null {
|
||||
if (!startDateStr || startDateStr.length < 8) return null;
|
||||
|
||||
// Parse YYYYMMDD format
|
||||
const year = parseInt(startDateStr.substring(0, 4), 10);
|
||||
const month = parseInt(startDateStr.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(startDateStr.substring(6, 8), 10);
|
||||
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
|
||||
|
||||
const startDate = new Date(year, month, day);
|
||||
// Minimum term is 3 months after signup month (signup month not included)
|
||||
// e.g., signup in January = minimum term ends April 30
|
||||
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 4, 0);
|
||||
|
||||
return endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cancellation preview with available months
|
||||
*/
|
||||
async getCancellationPreview(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<CancellationPreview> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
|
||||
// Calculate minimum contract end date
|
||||
const startDate = simDetails.startDate;
|
||||
const minEndDate = startDate ? this.calculateMinimumContractEndDate(startDate) : null;
|
||||
const today = new Date();
|
||||
const isWithinMinimumTerm = minEndDate ? today < minEndDate : false;
|
||||
|
||||
// Format minimum contract end date for display
|
||||
let minimumContractEndDate: string | undefined;
|
||||
if (minEndDate) {
|
||||
const year = minEndDate.getFullYear();
|
||||
const month = String(minEndDate.getMonth() + 1).padStart(2, "0");
|
||||
minimumContractEndDate = `${year}-${month}`;
|
||||
}
|
||||
|
||||
return {
|
||||
simNumber: validation.account,
|
||||
serialNumber: simDetails.iccid,
|
||||
planCode: simDetails.planCode,
|
||||
startDate,
|
||||
minimumContractEndDate,
|
||||
isWithinMinimumTerm,
|
||||
availableMonths: this.generateCancellationMonths(),
|
||||
customerEmail,
|
||||
customerName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service (legacy)
|
||||
*/
|
||||
async cancelSim(
|
||||
userId: string,
|
||||
@ -65,4 +195,123 @@ export class SimCancellationService {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service with full flow (PA02-04 and email notifications)
|
||||
*/
|
||||
async cancelSimFull(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimCancelFullRequest
|
||||
): Promise<void> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const account = validation.account;
|
||||
const simDetails = await this.freebitService.getSimDetails(account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
|
||||
// Validate confirmations
|
||||
if (!request.confirmRead || !request.confirmCancel) {
|
||||
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
||||
}
|
||||
|
||||
// Parse cancellation month and calculate runDate
|
||||
const [year, month] = request.cancellationMonth.split("-").map(Number);
|
||||
if (!year || !month) {
|
||||
throw new BadRequestException("Invalid cancellation month format");
|
||||
}
|
||||
|
||||
// runDate is 1st of the NEXT month (cancellation at end of selected month)
|
||||
const nextMonth = new Date(year, month, 1);
|
||||
const runYear = nextMonth.getFullYear();
|
||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const runDate = `${runYear}${runMonth}01`;
|
||||
|
||||
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
cancellationMonth: request.cancellationMonth,
|
||||
runDate,
|
||||
});
|
||||
|
||||
// Call PA02-04 cancellation API
|
||||
await this.freebitService.cancelAccount(account, runDate);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
runDate,
|
||||
});
|
||||
|
||||
// Send admin notification email
|
||||
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
|
||||
customerName,
|
||||
simNumber: account,
|
||||
serialNumber: simDetails.iccid,
|
||||
cancellationMonth: request.cancellationMonth,
|
||||
registeredEmail: customerEmail,
|
||||
otherEmail: request.alternativeEmail || undefined,
|
||||
comments: request.comments,
|
||||
});
|
||||
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"SonixNet SIM Online Cancellation",
|
||||
[
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/master/cnclAcnt/`,
|
||||
json: {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: {
|
||||
resultCode: "100",
|
||||
status: { message: "OK", statusCode: "200" },
|
||||
},
|
||||
},
|
||||
],
|
||||
adminEmailBody
|
||||
);
|
||||
|
||||
// Send confirmation email to customer (and alternative if provided)
|
||||
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
|
||||
const confirmationBody = `Dear ${customerName},
|
||||
|
||||
Your cancellation request for SIM #${account} has been confirmed.
|
||||
|
||||
The cancellation will take effect at the end of ${request.cancellationMonth}.
|
||||
|
||||
If you have any questions, please contact us at info@asolutions.co.jp
|
||||
|
||||
With best regards,
|
||||
Assist Solutions Customer Support
|
||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||
Email: info@asolutions.co.jp`;
|
||||
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
customerEmail,
|
||||
confirmationSubject,
|
||||
confirmationBody
|
||||
);
|
||||
|
||||
// Send to alternative email if provided
|
||||
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
request.alternativeEmail,
|
||||
confirmationSubject,
|
||||
confirmationBody
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,39 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim";
|
||||
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest, SimChangePlanFullRequest } from "@customer-portal/domain/sim";
|
||||
import { SimScheduleService } from "./sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||
import { SimManagementQueueService } from "../queue/sim-management.queue";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
|
||||
// Mapping from Salesforce SKU to Freebit plan code
|
||||
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
|
||||
"SIM-DATA-VOICE-5GB": "PASI_5G",
|
||||
"SIM-DATA-VOICE-10GB": "PASI_10G",
|
||||
"SIM-DATA-VOICE-25GB": "PASI_25G",
|
||||
"SIM-DATA-VOICE-50GB": "PASI_50G",
|
||||
"SIM-DATA-ONLY-5GB": "PASI_5G_DATA",
|
||||
"SIM-DATA-ONLY-10GB": "PASI_10G_DATA",
|
||||
"SIM-DATA-ONLY-25GB": "PASI_25G_DATA",
|
||||
"SIM-DATA-ONLY-50GB": "PASI_50G_DATA",
|
||||
"SIM-VOICE-ONLY": "PASI_VOICE",
|
||||
};
|
||||
|
||||
// Reverse mapping: Freebit plan code to Salesforce SKU
|
||||
const FREEBIT_PLAN_CODE_TO_SKU: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(SKU_TO_FREEBIT_PLAN_CODE).map(([sku, code]) => [code, sku])
|
||||
);
|
||||
|
||||
export interface AvailablePlan extends SimCatalogProduct {
|
||||
freebitPlanCode: string;
|
||||
isCurrentPlan: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimPlanService {
|
||||
@ -16,11 +43,71 @@ export class SimPlanService {
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly simQueue: SimManagementQueueService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly simCatalog: SimCatalogService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan
|
||||
* Get available plans for plan change
|
||||
* Filters by current plan type (e.g., only show DataSmsVoice plans if current is DataSmsVoice)
|
||||
*/
|
||||
async getAvailablePlans(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<AvailablePlan[]> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||
const currentPlanCode = simDetails.planCode;
|
||||
const currentSku = FREEBIT_PLAN_CODE_TO_SKU[currentPlanCode];
|
||||
|
||||
// Get all plans from Salesforce
|
||||
const allPlans = await this.simCatalog.getPlans();
|
||||
|
||||
// Determine current plan type
|
||||
let currentPlanType: string | undefined;
|
||||
if (currentSku) {
|
||||
const currentPlan = allPlans.find(p => p.sku === currentSku);
|
||||
currentPlanType = currentPlan?.simPlanType;
|
||||
}
|
||||
|
||||
// Filter plans by type (e.g., only show DataSmsVoice if current is DataSmsVoice)
|
||||
const filteredPlans = currentPlanType
|
||||
? allPlans.filter(p => p.simPlanType === currentPlanType)
|
||||
: allPlans.filter(p => !p.simHasFamilyDiscount); // Default: non-family plans
|
||||
|
||||
// Map to AvailablePlan with Freebit codes
|
||||
return filteredPlans.map(plan => {
|
||||
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku;
|
||||
return {
|
||||
...plan,
|
||||
freebitPlanCode,
|
||||
isCurrentPlan: freebitPlanCode === currentPlanCode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Freebit plan code from Salesforce SKU
|
||||
*/
|
||||
getFreebitPlanCode(sku: string): string | undefined {
|
||||
return SKU_TO_FREEBIT_PLAN_CODE[sku];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Salesforce SKU from Freebit plan code
|
||||
*/
|
||||
getSalesforceSku(planCode: string): string | undefined {
|
||||
return FREEBIT_PLAN_CODE_TO_SKU[planCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan (basic)
|
||||
*/
|
||||
async changeSimPlan(
|
||||
userId: string,
|
||||
@ -95,6 +182,84 @@ export class SimPlanService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan with enhanced notifications and Salesforce SKU mapping
|
||||
*/
|
||||
async changeSimPlanFull(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimChangePlanFullRequest
|
||||
): Promise<{ ipv4?: string; ipv6?: string; scheduledAt?: string }> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const account = validation.account;
|
||||
|
||||
// Get or derive Freebit plan code from SKU
|
||||
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[request.newPlanSku] || request.newPlanCode;
|
||||
|
||||
if (!freebitPlanCode || freebitPlanCode.length < 3) {
|
||||
throw new BadRequestException("Invalid plan code");
|
||||
}
|
||||
|
||||
// Always schedule for 1st of following month
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const scheduledAt = `${year}${month}01`;
|
||||
|
||||
this.logger.log("Submitting SIM plan change request (full)", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanSku: request.newPlanSku,
|
||||
freebitPlanCode,
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
const result = await this.freebitService.changeSimPlan(account, freebitPlanCode, {
|
||||
assignGlobalIp: request.assignGlobalIp ?? false,
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanCode: freebitPlanCode,
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
// Send API results email
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"API results - Plan Change",
|
||||
[
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/mvno/changePlan/`,
|
||||
json: {
|
||||
account,
|
||||
planCode: freebitPlanCode,
|
||||
runTime: scheduledAt,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: {
|
||||
resultCode: "100",
|
||||
status: { message: "OK", statusCode: "200" },
|
||||
ipv4: result.ipv4 || "",
|
||||
ipv6: result.ipv6 || "",
|
||||
},
|
||||
},
|
||||
],
|
||||
`Plan changed to: ${request.newPlanName || freebitPlanCode}\nScheduled for: ${scheduledAt}`
|
||||
);
|
||||
|
||||
return {
|
||||
ipv4: result.ipv4,
|
||||
ipv6: result.ipv6,
|
||||
scheduledAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||
*/
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
@ -7,6 +8,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
||||
import { SimBillingService } from "./sim-billing.service";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
|
||||
@Injectable()
|
||||
export class SimTopUpService {
|
||||
@ -16,9 +18,19 @@ export class SimTopUpService {
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simBilling: SimBillingService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get whmcsBaseUrl(): string {
|
||||
return this.configService.get<string>("WHMCS_BASE_URL") || "https://accounts.asolutions.co.jp/includes/api.php";
|
||||
}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Top up SIM data quota with payment processing
|
||||
* Pricing: 1GB = 500 JPY
|
||||
@ -88,8 +100,11 @@ export class SimTopUpService {
|
||||
metadata: { subscriptionId },
|
||||
});
|
||||
|
||||
// Call Freebit API to add quota
|
||||
let freebitResult: { resultCode: string; status: { message: string; statusCode: string } } | null = null;
|
||||
try {
|
||||
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
||||
freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } };
|
||||
} catch (freebitError) {
|
||||
await this.handleFreebitFailureAfterPayment(
|
||||
freebitError,
|
||||
@ -112,6 +127,52 @@ export class SimTopUpService {
|
||||
transactionId: billing.transactionId,
|
||||
});
|
||||
|
||||
// Send API results email notification
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split("T")[0];
|
||||
const dueDate = new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"API results",
|
||||
[
|
||||
{
|
||||
url: this.whmcsBaseUrl,
|
||||
senddata: {
|
||||
itemdescription1: `Top-up data (${units}GB)\nSIM Number: ${latestAccount}`,
|
||||
itemamount1: String(costJpy),
|
||||
userid: String(mapping.whmcsClientId),
|
||||
date: dateStr,
|
||||
responsetype: "json",
|
||||
itemtaxed1: "1",
|
||||
action: "CreateInvoice",
|
||||
duedate: dueDate,
|
||||
paymentmethod: "stripe",
|
||||
sendinvoice: "1",
|
||||
},
|
||||
result: { result: "success", invoiceid: billing.invoice.id, status: billing.invoice.status },
|
||||
},
|
||||
{
|
||||
url: this.whmcsBaseUrl,
|
||||
senddata: {
|
||||
responsetype: "json",
|
||||
action: "CapturePayment",
|
||||
invoiceid: billing.invoice.id,
|
||||
},
|
||||
result: { result: "success" },
|
||||
},
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/master/addSpec/`,
|
||||
json: {
|
||||
quota: request.quotaMb,
|
||||
kind: "MVNO",
|
||||
account: latestAccount,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: freebitResult || { resultCode: "100", status: { message: "OK", statusCode: "200" } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
account: latestAccount,
|
||||
costJpy,
|
||||
|
||||
@ -4,6 +4,7 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||
import { EmailModule } from "@bff/infra/email/email.module";
|
||||
import { SftpModule } from "@bff/integrations/sftp/sftp.module";
|
||||
import { SimUsageStoreService } from "../sim-usage-store.service";
|
||||
import { SubscriptionsService } from "../subscriptions.service";
|
||||
|
||||
@ -18,12 +19,15 @@ import { SimCancellationService } from "./services/sim-cancellation.service";
|
||||
import { EsimManagementService } from "./services/esim-management.service";
|
||||
import { SimValidationService } from "./services/sim-validation.service";
|
||||
import { SimNotificationService } from "./services/sim-notification.service";
|
||||
import { SimApiNotificationService } from "./services/sim-api-notification.service";
|
||||
import { SimBillingService } from "./services/sim-billing.service";
|
||||
import { SimScheduleService } from "./services/sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./services/sim-action-runner.service";
|
||||
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
||||
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
import { SimCallHistoryService } from "./services/sim-call-history.service";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -32,6 +36,8 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
SalesforceModule,
|
||||
MappingsModule,
|
||||
EmailModule,
|
||||
CatalogModule,
|
||||
SftpModule,
|
||||
],
|
||||
providers: [
|
||||
// Core services that the SIM services depend on
|
||||
@ -41,6 +47,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
// SIM management services
|
||||
SimValidationService,
|
||||
SimNotificationService,
|
||||
SimApiNotificationService,
|
||||
SimVoiceOptionsService,
|
||||
SimDetailsService,
|
||||
SimUsageService,
|
||||
@ -55,6 +62,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimManagementProcessor,
|
||||
SimCallHistoryService,
|
||||
// Export with token for optional injection in Freebit module
|
||||
{
|
||||
provide: "SimVoiceOptionsService",
|
||||
@ -73,11 +81,13 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
EsimManagementService,
|
||||
SimValidationService,
|
||||
SimNotificationService,
|
||||
SimApiNotificationService,
|
||||
SimBillingService,
|
||||
SimScheduleService,
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimVoiceOptionsService,
|
||||
SimCallHistoryService,
|
||||
"SimVoiceOptionsService", // Export the token
|
||||
],
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
UsePipes,
|
||||
Header,
|
||||
} from "@nestjs/common";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimManagementService } from "./sim-management.service";
|
||||
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service";
|
||||
@ -32,13 +33,21 @@ import {
|
||||
simChangePlanRequestSchema,
|
||||
simCancelRequestSchema,
|
||||
simFeaturesRequestSchema,
|
||||
simCancelFullRequestSchema,
|
||||
simChangePlanFullRequestSchema,
|
||||
type SimTopupRequest,
|
||||
type SimChangePlanRequest,
|
||||
type SimCancelRequest,
|
||||
type SimFeaturesRequest,
|
||||
type SimCancelFullRequest,
|
||||
type SimChangePlanFullRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
import { SimPlanService } from "./sim-management/services/sim-plan.service";
|
||||
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service";
|
||||
import { EsimManagementService, type ReissueSimRequest } from "./sim-management/services/esim-management.service";
|
||||
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service";
|
||||
|
||||
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||
defaultLimit: 10,
|
||||
@ -52,7 +61,11 @@ export class SubscriptionsController {
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly simManagementService: SimManagementService,
|
||||
private readonly simTopUpPricingService: SimTopUpPricingService
|
||||
private readonly simTopUpPricingService: SimTopUpPricingService,
|
||||
private readonly simPlanService: SimPlanService,
|
||||
private readonly simCancellationService: SimCancellationService,
|
||||
private readonly esimManagementService: EsimManagementService,
|
||||
private readonly simCallHistoryService: SimCallHistoryService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -78,25 +91,47 @@ export class SubscriptionsController {
|
||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
async getSubscriptionById(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
||||
}
|
||||
@Get(":id/invoices")
|
||||
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
|
||||
): Promise<InvoiceList> {
|
||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
|
||||
// ==================== Static SIM Routes (must be before :id routes) ====================
|
||||
|
||||
/**
|
||||
* Get available months for call/SMS history
|
||||
*/
|
||||
@Public()
|
||||
@Get("sim/call-history/available-months")
|
||||
@Header("Cache-Control", "public, max-age=3600")
|
||||
async getAvailableMonths() {
|
||||
const months = await this.simCallHistoryService.getAvailableMonths();
|
||||
return { success: true, data: months };
|
||||
}
|
||||
|
||||
// ==================== SIM Management Endpoints ====================
|
||||
/**
|
||||
* List available files on SFTP for debugging
|
||||
*/
|
||||
@Public()
|
||||
@Get("sim/call-history/sftp-files")
|
||||
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
|
||||
const files = await this.simCallHistoryService.listSftpFiles(path);
|
||||
return { success: true, data: files, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual import of call history (admin only)
|
||||
* TODO: Add proper admin authentication before production
|
||||
*/
|
||||
@Public()
|
||||
@Post("sim/call-history/import")
|
||||
async importCallHistory(@Query("month") yearMonth: string) {
|
||||
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
|
||||
throw new BadRequestException("Invalid month format (expected YYYYMM)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.importCallHistory(yearMonth);
|
||||
return {
|
||||
success: true,
|
||||
message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("sim/top-up/pricing")
|
||||
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing is relatively static
|
||||
@ -122,6 +157,29 @@ export class SubscriptionsController {
|
||||
return await this.simManagementService.getSimDetailsDebug(account);
|
||||
}
|
||||
|
||||
// ==================== Dynamic :id Routes ====================
|
||||
|
||||
@Get(":id")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
async getSubscriptionById(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(":id/invoices")
|
||||
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
|
||||
): Promise<InvoiceList> {
|
||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
|
||||
}
|
||||
|
||||
// ==================== SIM Management Endpoints (subscription-specific) ====================
|
||||
|
||||
@Get(":id/sim/debug")
|
||||
async debugSimSubscription(
|
||||
@Request() req: RequestWithUser,
|
||||
@ -228,4 +286,186 @@ export class SubscriptionsController {
|
||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM features updated successfully" };
|
||||
}
|
||||
|
||||
// ==================== Enhanced SIM Management Endpoints ====================
|
||||
|
||||
/**
|
||||
* Get available plans for plan change (filtered by current plan type)
|
||||
*/
|
||||
@Get(":id/sim/available-plans")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getAvailablePlans(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
const plans = await this.simPlanService.getAvailablePlans(req.user.id, subscriptionId);
|
||||
return { success: true, data: plans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan with enhanced flow (Salesforce SKU mapping + email notifications)
|
||||
*/
|
||||
@Post(":id/sim/change-plan-full")
|
||||
@UsePipes(new ZodValidationPipe(simChangePlanFullRequestSchema))
|
||||
async changeSimPlanFull(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimChangePlanFullRequest
|
||||
): Promise<SimPlanChangeResult> {
|
||||
const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: `SIM plan change scheduled for ${result.scheduledAt}`,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cancellation preview (available months, customer info, minimum contract term)
|
||||
*/
|
||||
@Get(":id/sim/cancellation-preview")
|
||||
@Header("Cache-Control", "private, max-age=60")
|
||||
async getCancellationPreview(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
const preview = await this.simCancellationService.getCancellationPreview(
|
||||
req.user.id,
|
||||
subscriptionId
|
||||
);
|
||||
return { success: true, data: preview };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM with full flow (PA02-04 + email notifications)
|
||||
*/
|
||||
@Post(":id/sim/cancel-full")
|
||||
@UsePipes(new ZodValidationPipe(simCancelFullRequestSchema))
|
||||
async cancelSimFull(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimCancelFullRequest
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: `SIM cancellation scheduled for end of ${body.cancellationMonth}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue SIM (both eSIM and physical SIM)
|
||||
*/
|
||||
@Post(":id/sim/reissue")
|
||||
async reissueSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: ReissueSimRequest
|
||||
): Promise<SimActionResponse> {
|
||||
await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body);
|
||||
|
||||
if (body.simType === "esim") {
|
||||
return { success: true, message: "eSIM profile reissue request submitted" };
|
||||
} else {
|
||||
return { success: true, message: "Physical SIM reissue request submitted. You will be contacted shortly." };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Call/SMS History Endpoints ====================
|
||||
|
||||
/**
|
||||
* Get domestic call history
|
||||
*/
|
||||
@Get(":id/sim/call-history/domestic")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getDomesticCallHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.getDomesticCallHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get international call history
|
||||
*/
|
||||
@Get(":id/sim/call-history/international")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getInternationalCallHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.getInternationalCallHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMS history
|
||||
*/
|
||||
@Get(":id/sim/sms-history")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getSmsHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.getSmsHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory";
|
||||
|
||||
export default function SimCallHistoryPage() {
|
||||
return <SimCallHistoryContainer />;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export default function Page() {
|
||||
return null;
|
||||
import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
|
||||
|
||||
export default function SimReissuePage() {
|
||||
return <SimReissueContainer />;
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
SignalIcon,
|
||||
PhoneIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
XCircleIcon,
|
||||
@ -49,10 +50,18 @@ interface SimInfo {
|
||||
}
|
||||
|
||||
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
||||
const router = useRouter();
|
||||
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Navigation handlers
|
||||
const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
const navigateToCallHistory = () => router.push(`/subscriptions/${subscriptionId}/sim/call-history`);
|
||||
|
||||
// Fetch subscription data
|
||||
const { data: subscription } = useSubscription(subscriptionId);
|
||||
|
||||
@ -234,7 +243,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
{/* Top Up Data Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {/* TODO: Open top-up modal */}}
|
||||
onClick={navigateToTopUp}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Top Up Data
|
||||
@ -281,15 +290,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => {/* TODO: Open top-up modal */}}
|
||||
onClick={navigateToCallHistory}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<SignalIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-900">Data Top up</span>
|
||||
<PhoneIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-900">Call History</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Open change plan modal */}}
|
||||
onClick={navigateToChangePlan}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
@ -297,16 +306,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Open reissue modal */}}
|
||||
disabled={simInfo.details.simType !== 'esim'}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={navigateToReissue}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Open cancel modal */}}
|
||||
onClick={navigateToCancel}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-red-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<XCircleIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
|
||||
import { simInfoSchema, type SimInfo, type SimCancelFullRequest, type SimChangePlanFullRequest } from "@customer-portal/domain/sim";
|
||||
import type {
|
||||
SimTopUpRequest,
|
||||
SimPlanChangeRequest,
|
||||
@ -13,6 +13,41 @@ import type {
|
||||
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
|
||||
// - SimCancelRequest: scheduledAt (YYYYMMDD format)
|
||||
|
||||
export interface AvailablePlan {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
description?: string;
|
||||
monthlyPrice?: number;
|
||||
simDataSize?: string;
|
||||
simPlanType?: string;
|
||||
freebitPlanCode: string;
|
||||
isCurrentPlan: boolean;
|
||||
}
|
||||
|
||||
export interface CancellationMonth {
|
||||
value: string;
|
||||
label: string;
|
||||
runDate: string;
|
||||
}
|
||||
|
||||
export interface CancellationPreview {
|
||||
simNumber: string;
|
||||
serialNumber?: string;
|
||||
planCode: string;
|
||||
startDate?: string;
|
||||
minimumContractEndDate?: string;
|
||||
isWithinMinimumTerm: boolean;
|
||||
availableMonths: CancellationMonth[];
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
export interface ReissueSimRequest {
|
||||
simType: "physical" | "esim";
|
||||
newEid?: string;
|
||||
}
|
||||
|
||||
export const simActionsService = {
|
||||
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
|
||||
@ -28,6 +63,17 @@ export const simActionsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async changePlanFull(subscriptionId: string, request: SimChangePlanFullRequest): Promise<{ scheduledAt?: string }> {
|
||||
const response = await apiClient.POST<{ success: boolean; message: string; scheduledAt?: string }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
}
|
||||
);
|
||||
return { scheduledAt: response.data?.scheduledAt };
|
||||
},
|
||||
|
||||
async cancel(subscriptionId: string, request: SimCancelRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", {
|
||||
params: { path: { subscriptionId } },
|
||||
@ -35,6 +81,13 @@ export const simActionsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async cancelFull(subscriptionId: string, request: SimCancelFullRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel-full", {
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
});
|
||||
},
|
||||
|
||||
async getSimInfo(subscriptionId: string): Promise<SimInfo | null> {
|
||||
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
|
||||
params: { path: { subscriptionId } },
|
||||
@ -47,10 +100,153 @@ export const simActionsService = {
|
||||
return simInfoSchema.parse(response.data);
|
||||
},
|
||||
|
||||
async reissueEsim(subscriptionId: string, request: SimReissueRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/reissue-esim", {
|
||||
async getAvailablePlans(subscriptionId: string): Promise<AvailablePlan[]> {
|
||||
const response = await apiClient.GET<{ success: boolean; data: AvailablePlan[] }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/available-plans",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
}
|
||||
);
|
||||
return response.data?.data || [];
|
||||
},
|
||||
|
||||
async getCancellationPreview(subscriptionId: string): Promise<CancellationPreview | null> {
|
||||
const response = await apiClient.GET<{ success: boolean; data: CancellationPreview }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/cancellation-preview",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async reissueSim(subscriptionId: string, request: ReissueSimRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/reissue", {
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
});
|
||||
},
|
||||
|
||||
// Call/SMS History
|
||||
|
||||
async getDomesticCallHistory(
|
||||
subscriptionId: string,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<DomesticCallHistoryResponse | null> {
|
||||
const params: Record<string, string> = {};
|
||||
if (month) params.month = month;
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: DomesticCallHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/call-history/domestic",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async getInternationalCallHistory(
|
||||
subscriptionId: string,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<InternationalCallHistoryResponse | null> {
|
||||
const params: Record<string, string> = {};
|
||||
if (month) params.month = month;
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: InternationalCallHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/call-history/international",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async getSmsHistory(
|
||||
subscriptionId: string,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<SmsHistoryResponse | null> {
|
||||
const params: Record<string, string> = {};
|
||||
if (month) params.month = month;
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: SmsHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/sms-history",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async getAvailableHistoryMonths(): Promise<string[]> {
|
||||
const response = await apiClient.GET<{ success: boolean; data: string[] }>(
|
||||
"/api/subscriptions/sim/call-history/available-months",
|
||||
{}
|
||||
);
|
||||
return response.data?.data || [];
|
||||
},
|
||||
};
|
||||
|
||||
// Additional types for call/SMS history
|
||||
export interface CallHistoryPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DomesticCallRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
calledTo: string;
|
||||
callLength: string;
|
||||
callCharge: number;
|
||||
}
|
||||
|
||||
export interface DomesticCallHistoryResponse {
|
||||
calls: DomesticCallRecord[];
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface InternationalCallRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
stopTime: string | null;
|
||||
country: string | null;
|
||||
calledTo: string;
|
||||
callCharge: number;
|
||||
}
|
||||
|
||||
export interface InternationalCallHistoryResponse {
|
||||
calls: InternationalCallRecord[];
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
sentTo: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SmsHistoryResponse {
|
||||
messages: SmsRecord[];
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
434
apps/portal/src/features/subscriptions/views/SimCallHistory.tsx
Normal file
434
apps/portal/src/features/subscriptions/views/SimCallHistory.tsx
Normal file
@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { PhoneIcon, GlobeAltIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
simActionsService,
|
||||
type DomesticCallHistoryResponse,
|
||||
type InternationalCallHistoryResponse,
|
||||
type SmsHistoryResponse,
|
||||
} from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
type TabType = "domestic" | "international" | "sms";
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimCallHistoryContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>("domestic");
|
||||
// Use September 2025 as the current month (latest available - 2 months behind)
|
||||
const currentMonth = "2025-09";
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Data states
|
||||
const [domesticData, setDomesticData] = useState<DomesticCallHistoryResponse | null>(null);
|
||||
const [internationalData, setInternationalData] = useState<InternationalCallHistoryResponse | null>(null);
|
||||
const [smsData, setSmsData] = useState<SmsHistoryResponse | null>(null);
|
||||
|
||||
// Pagination states
|
||||
const [domesticPage, setDomesticPage] = useState(1);
|
||||
const [internationalPage, setInternationalPage] = useState(1);
|
||||
const [smsPage, setSmsPage] = useState(1);
|
||||
|
||||
// Fetch data when tab changes
|
||||
useEffect(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (activeTab === "domestic") {
|
||||
const data = await simActionsService.getDomesticCallHistory(
|
||||
subscriptionId,
|
||||
currentMonth,
|
||||
domesticPage,
|
||||
50
|
||||
);
|
||||
setDomesticData(data);
|
||||
} else if (activeTab === "international") {
|
||||
const data = await simActionsService.getInternationalCallHistory(
|
||||
subscriptionId,
|
||||
currentMonth,
|
||||
internationalPage,
|
||||
50
|
||||
);
|
||||
setInternationalData(data);
|
||||
} else if (activeTab === "sms") {
|
||||
const data = await simActionsService.getSmsHistory(
|
||||
subscriptionId,
|
||||
currentMonth,
|
||||
smsPage,
|
||||
50
|
||||
);
|
||||
setSmsData(data);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load history");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
}, [subscriptionId, activeTab, domesticPage, internationalPage, smsPage]);
|
||||
|
||||
// Reset page when tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === "domestic") setDomesticPage(1);
|
||||
if (activeTab === "international") setInternationalPage(1);
|
||||
if (activeTab === "sms") setSmsPage(1);
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<PhoneIcon />}
|
||||
title="Call & SMS History"
|
||||
description="View your call and SMS records"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SubCard>
|
||||
{/* Current Month Display */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
Showing data for: September 2025
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Call/SMS history is available approximately 2 months after the calls are made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex -mb-px space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("domestic")}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "domestic"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
Domestic Calls
|
||||
{domesticData && (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({domesticData.pagination.total})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("international")}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "international"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<GlobeAltIcon className="h-5 w-5" />
|
||||
International Calls
|
||||
{internationalData && (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({internationalData.pagination.total})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("sms")}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "sms"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<ChatBubbleLeftIcon className="h-5 w-5" />
|
||||
SMS History
|
||||
{smsData && (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({smsData.pagination.total})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<AlertBanner variant="error" title="Error">
|
||||
{error}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-12 bg-gray-100 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domestic Calls Table */}
|
||||
{!loading && activeTab === "domestic" && domesticData && (
|
||||
<>
|
||||
{domesticData.calls.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No domestic calls found for this month
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Called To
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Call Length
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Call Charge (¥)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{domesticData.calls.map(call => (
|
||||
<tr key={call.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{call.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.time}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{call.calledTo}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.callLength}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
{formatCurrency(call.callCharge)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={domesticData.pagination.page}
|
||||
totalPages={domesticData.pagination.totalPages}
|
||||
onPageChange={setDomesticPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* International Calls Table */}
|
||||
{!loading && activeTab === "international" && internationalData && (
|
||||
<>
|
||||
{internationalData.calls.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No international calls found for this month
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Start Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stop Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Country
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Called To
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Call Charge (¥)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{internationalData.calls.map(call => (
|
||||
<tr key={call.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{call.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.startTime}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.stopTime || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{call.country || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{call.calledTo}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
{formatCurrency(call.callCharge)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={internationalData.pagination.page}
|
||||
totalPages={internationalData.pagination.totalPages}
|
||||
onPageChange={setInternationalPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SMS Table */}
|
||||
{!loading && activeTab === "sms" && smsData && (
|
||||
<>
|
||||
{smsData.messages.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No SMS found for this month
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Sent To
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{smsData.messages.map(msg => (
|
||||
<tr key={msg.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{msg.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{msg.time}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{msg.sentTo}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
|
||||
msg.type === "International SMS"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{msg.type}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={smsData.pagination.page}
|
||||
totalPages={smsData.pagination.totalPages}
|
||||
onPageChange={setSmsPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Important Notes</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Call/SMS history is updated approximately 2 months after the calls/messages are made</li>
|
||||
<li>• The history shows approximately 3 months of records</li>
|
||||
<li>• Call charges shown are based on the carrier billing data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubCard>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimCallHistoryContainer;
|
||||
|
||||
@ -2,18 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { simActionsService, type CancellationPreview } from "@/features/subscriptions/services/sim-actions.service";
|
||||
|
||||
type Step = 1 | 2 | 3;
|
||||
|
||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
|
||||
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
|
||||
<div className="text-sm text-yellow-800">{children}</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-4">
|
||||
<div className="text-sm font-semibold text-yellow-900 mb-2">{title}</div>
|
||||
<div className="text-sm text-yellow-800 leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -34,79 +32,63 @@ export function SimCancelContainer() {
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [details, setDetails] = useState<SimDetails | null>(null);
|
||||
const [preview, setPreview] = useState<CancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
||||
const [cancelMonth, setCancelMonth] = useState<string>("");
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [email2, setEmail2] = useState<string>("");
|
||||
const [notes, setNotes] = useState<string>("");
|
||||
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
||||
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
||||
const [comments, setComments] = useState<string>("");
|
||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const info = await simActionsService.getSimInfo(subscriptionId);
|
||||
setDetails(info?.details || null);
|
||||
const data = await simActionsService.getCancellationPreview(subscriptionId);
|
||||
setPreview(data);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
||||
setError(e instanceof Error ? e.message : "Failed to load cancellation information");
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
void fetchDetails();
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEmail = () => {
|
||||
try {
|
||||
const emailFromStore = useAuthStore.getState().user?.email;
|
||||
if (emailFromStore) {
|
||||
setRegisteredEmail(emailFromStore);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
fetchEmail();
|
||||
}, []);
|
||||
|
||||
const monthOptions = useMemo(() => {
|
||||
const opts: { value: string; label: string }[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1));
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
opts.push({ value: `${y}${m}`, label: `${y} / ${m}` });
|
||||
}
|
||||
return opts;
|
||||
}, []);
|
||||
|
||||
const canProceedStep2 = !!details;
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const emailProvided = email.trim().length > 0 || email2.trim().length > 0;
|
||||
const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0;
|
||||
const emailValid =
|
||||
!emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim()));
|
||||
const emailsMatch = !emailProvided || email.trim() === email2.trim();
|
||||
const canProceedStep3 =
|
||||
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const runDate = cancelMonth ? `${cancelMonth}01` : null;
|
||||
!emailProvided ||
|
||||
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
|
||||
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
|
||||
const canProceedStep2 = !!preview && !!selectedMonth;
|
||||
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
|
||||
|
||||
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
if (!runDate) {
|
||||
|
||||
if (!selectedMonth) {
|
||||
setError("Please select a cancellation month before submitting.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
|
||||
await simActionsService.cancelFull(subscriptionId, {
|
||||
cancellationMonth: selectedMonth,
|
||||
confirmRead: acceptTerms,
|
||||
confirmCancel: confirmMonthEnd,
|
||||
alternativeEmail: alternativeEmail.trim() || undefined,
|
||||
comments: comments.trim() || undefined,
|
||||
});
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
|
||||
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 2000);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
|
||||
} finally {
|
||||
@ -114,6 +96,17 @@ export function SimCancelContainer() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingPreview) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<div className="mb-4">
|
||||
@ -123,14 +116,24 @@ export function SimCancelContainer() {
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
<div className="text-sm text-gray-500">Step {step} of 3</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{[1, 2, 3].map(s => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
s <= step ? "bg-blue-600" : "bg-gray-200"
|
||||
}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Step {step} of 3</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>
|
||||
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-4 mb-4">{error}</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
||||
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-4 mb-4">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
@ -138,44 +141,59 @@ export function SimCancelContainer() {
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
|
||||
terminate your service immediately.
|
||||
Cancel your SIM subscription. Please read all the information carefully before proceeding.
|
||||
</p>
|
||||
|
||||
{/* Minimum Contract Warning */}
|
||||
{preview?.isWithinMinimumTerm && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-semibold text-red-900 mb-1">Minimum Contract Term Warning</div>
|
||||
<div className="text-sm text-red-800">
|
||||
Your subscription is still within the minimum contract period (ends {preview.minimumContractEndDate}).
|
||||
Early cancellation may result in additional charges for the remaining months.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cancellation Month
|
||||
</label>
|
||||
<select
|
||||
value={cancelMonth}
|
||||
onChange={e => {
|
||||
setCancelMonth(e.target.value);
|
||||
setConfirmMonthEnd(false);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select month…</option>
|
||||
{monthOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Cancellation takes effect at the start of the selected month.
|
||||
</p>
|
||||
</div>
|
||||
{/* SIM Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
|
||||
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
|
||||
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
|
||||
</div>
|
||||
|
||||
{/* Month Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Cancellation Month
|
||||
</label>
|
||||
<select
|
||||
value={selectedMonth}
|
||||
onChange={e => {
|
||||
setSelectedMonth(e.target.value);
|
||||
setConfirmMonthEnd(false);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select month…</option>
|
||||
{preview?.availableMonths.map(month => (
|
||||
<option key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Your subscription will be cancelled at the end of the selected month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
disabled={!canProceedStep2}
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@ -185,8 +203,8 @@ export function SimCancelContainer() {
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Notice title="Cancellation Procedure">
|
||||
<div className="space-y-4">
|
||||
<Notice title="[Cancellation Procedure]">
|
||||
Online cancellations must be made from this website by the 25th of the desired
|
||||
cancellation month. Once a request of a cancellation of the SONIXNET SIM is accepted
|
||||
from this online form, a confirmation email containing details of the SIM plan will
|
||||
@ -196,63 +214,73 @@ export function SimCancelContainer() {
|
||||
services with Assist Solutions (home internet etc.) please contact Assist Solutions
|
||||
at info@asolutions.co.jp
|
||||
</Notice>
|
||||
<Notice title="Minimum Contract Term">
|
||||
|
||||
<Notice title="[Minimum Contract Term]">
|
||||
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
|
||||
month is not included in the minimum term of three months; ie. sign-up in January =
|
||||
minimum term is February, March, April). If the minimum contract term is not
|
||||
fulfilled, the monthly fees of the remaining months will be charged upon
|
||||
cancellation.
|
||||
</Notice>
|
||||
<Notice title="Option Services">
|
||||
|
||||
<Notice title="[Cancellation of Option Services (for Data+SMS/Voice Plan)]">
|
||||
Cancellation of option services only (Voice Mail, Call Waiting) while keeping the
|
||||
base plan active is not possible from this online form. Please contact Assist
|
||||
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
|
||||
cancelling the base plan, all additional options associated with the requested SIM
|
||||
plan will be cancelled.
|
||||
</Notice>
|
||||
<Notice title="MNP Transfer (Voice Plans)">
|
||||
|
||||
<Notice title="[MNP Transfer (for Data+SMS/Voice Plan)]">
|
||||
Upon cancellation the SIM phone number will be lost. In order to keep the phone
|
||||
number active to be used with a different cellular provider, a request for an MNP
|
||||
transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be
|
||||
requested from this online form. Please contact Assist Solutions Customer Support
|
||||
transfer (administrative fee ¥1,000+tax) is necessary. The MNP cannot be requested
|
||||
from this online form. Please contact Assist Solutions Customer Support
|
||||
(info@asolutions.co.jp) for more information.
|
||||
</Notice>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={acceptTerms}
|
||||
onChange={e => setAcceptTerms(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
|
||||
I have read and accepted the conditions above.
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
id="confirmMonthEnd"
|
||||
type="checkbox"
|
||||
checked={confirmMonthEnd}
|
||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
||||
disabled={!cancelMonth}
|
||||
/>
|
||||
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
|
||||
I would like to cancel my SonixNet SIM subscription at the end of the selected month
|
||||
above.
|
||||
</label>
|
||||
|
||||
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={acceptTerms}
|
||||
onChange={e => setAcceptTerms(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
|
||||
I have read and accepted the conditions above.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="confirmMonthEnd"
|
||||
type="checkbox"
|
||||
checked={confirmMonthEnd}
|
||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
||||
disabled={!selectedMonth}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
|
||||
I would like to cancel my SonixNet SIM subscription at the end of{" "}
|
||||
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
disabled={!canProceedStep3}
|
||||
onClick={() => setStep(3)}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@ -262,65 +290,93 @@ export function SimCancelContainer() {
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
{registeredEmail && (
|
||||
<div className="text-sm text-gray-800">
|
||||
Your registered email address is:{" "}
|
||||
<span className="font-medium">{registeredEmail}</span>
|
||||
{/* Voice SIM Notice */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">
|
||||
For Voice-enabled SIM subscriptions:
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="text-sm text-blue-800">
|
||||
Calling charges are post payment. Your bill for the final month's calling charges
|
||||
will be charged on your credit card on file during the first week of the second month
|
||||
after the cancellation.
|
||||
</div>
|
||||
<div className="text-sm text-blue-800 mt-2">
|
||||
If you would like to make the payment with a different credit card, please contact
|
||||
Assist Solutions at info@asolutions.co.jp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registered Email */}
|
||||
<div className="text-sm text-gray-800">
|
||||
Your registered email address is:{" "}
|
||||
<span className="font-medium">{preview?.customerEmail || "—"}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
You will receive a cancellation confirmation email. If you would like to receive this
|
||||
email on a different address, please enter the address below.
|
||||
</div>
|
||||
|
||||
{/* Alternative Email */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email address</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email address:
|
||||
</label>
|
||||
<input
|
||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
value={alternativeEmail}
|
||||
onChange={e => setAlternativeEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">(Confirm)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">(Confirm):</label>
|
||||
<input
|
||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
value={email2}
|
||||
onChange={e => setEmail2(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
value={alternativeEmail2}
|
||||
onChange={e => setAlternativeEmail2(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
If you have any other questions/comments/requests regarding your cancellation,
|
||||
please note them below and an Assist Solutions staff will contact you shortly.
|
||||
</label>
|
||||
<textarea
|
||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="If you have any questions or requests, note them here."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emailProvided && !emailValid && (
|
||||
<div className="text-xs text-red-600">
|
||||
Please enter a valid email address in both fields.
|
||||
</div>
|
||||
<div className="text-xs text-red-600">Please enter a valid email address in both fields.</div>
|
||||
)}
|
||||
{emailProvided && emailValid && !emailsMatch && (
|
||||
<div className="text-xs text-red-600">Email addresses do not match.</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-700">
|
||||
Your cancellation request is not confirmed yet. This is the final page. To finalize
|
||||
your cancellation request please proceed from REQUEST CANCELLATION below.
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
If you have any other questions/comments/requests regarding your cancellation, please
|
||||
note them below and an Assist Solutions staff will contact you shortly.
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={4}
|
||||
value={comments}
|
||||
onChange={e => setComments(e.target.value)}
|
||||
placeholder="Optional: Enter any questions or requests here."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Final Warning */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-red-900 mb-1">
|
||||
Your cancellation request is not confirmed yet.
|
||||
</div>
|
||||
<div className="text-sm text-red-800">
|
||||
This is the final page. To finalize your cancellation request please proceed from
|
||||
REQUEST CANCELLATION below.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
@ -328,18 +384,16 @@ export function SimCancelContainer() {
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Request cancellation now? This will schedule the cancellation for " +
|
||||
(runDate || "") +
|
||||
"."
|
||||
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
|
||||
)
|
||||
) {
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
disabled={loading || !runDate || !canProceedStep3}
|
||||
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
|
||||
disabled={loading || !canProceedStep3}
|
||||
className="px-6 py-2 rounded-md bg-red-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{loading ? "Processing…" : "Request Cancellation"}
|
||||
{loading ? "Processing…" : "REQUEST CANCELLATION"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,39 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService, type AvailablePlan } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
import {
|
||||
SIM_PLAN_OPTIONS,
|
||||
type SimPlanCode,
|
||||
getSimPlanLabel,
|
||||
} from "@customer-portal/domain/sim";
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
export function SimChangePlanContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = params.id as string;
|
||||
const [currentPlanCode] = useState<string>("");
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
|
||||
const [plans, setPlans] = useState<AvailablePlan[]>([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState<AvailablePlan | null>(null);
|
||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||
|
||||
const options = useMemo(
|
||||
() => SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode),
|
||||
[currentPlanCode]
|
||||
);
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const availablePlans = await simActionsService.getAvailablePlans(subscriptionId);
|
||||
setPlans(availablePlans);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load available plans");
|
||||
} finally {
|
||||
setLoadingPlans(false);
|
||||
}
|
||||
};
|
||||
void fetchPlans();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const currentPlan = plans.find(p => p.isCurrentPlan);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPlanCode) {
|
||||
if (!selectedPlan) {
|
||||
setError("Please select a new plan");
|
||||
return;
|
||||
}
|
||||
@ -41,12 +49,14 @@ export function SimChangePlanContainer() {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await simActionsService.changePlan(subscriptionId, {
|
||||
newPlanCode,
|
||||
const result = await simActionsService.changePlanFull(subscriptionId, {
|
||||
newPlanCode: selectedPlan.freebitPlanCode,
|
||||
newPlanSku: selectedPlan.sku,
|
||||
newPlanName: selectedPlan.name,
|
||||
assignGlobalIp,
|
||||
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
|
||||
});
|
||||
setMessage("Plan change submitted successfully");
|
||||
setMessage(`Plan change scheduled for ${result.scheduledAt || "the 1st of next month"}`);
|
||||
setSelectedPlan(null);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to change plan");
|
||||
} finally {
|
||||
@ -60,7 +70,7 @@ export function SimChangePlanContainer() {
|
||||
title="Change Plan"
|
||||
description="Switch to a different data plan"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
@ -71,11 +81,13 @@ export function SimChangePlanContainer() {
|
||||
</div>
|
||||
|
||||
<SubCard>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Change Plan: Switch to a different data plan. Important: Plan changes must be requested
|
||||
before the 25th of the month. Changes will take effect on the 1st of the following
|
||||
month.
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Change Your Plan</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Select a new plan below. Plan changes will take effect on the 1st of the following month.
|
||||
Changes must be requested before the 25th of the current month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="mb-4">
|
||||
@ -92,64 +104,127 @@ export function SimChangePlanContainer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
||||
<select
|
||||
value={newPlanCode}
|
||||
onChange={e => setNewPlanCode(e.target.value as SimPlanCode)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">Choose a plan</option>
|
||||
{options.map(option => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{getSimPlanLabel(option.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingPlans ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 bg-gray-100 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
{currentPlan && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-blue-600 font-medium uppercase">Current Plan</div>
|
||||
<div className="text-lg font-semibold text-blue-900">{currentPlan.name}</div>
|
||||
<div className="text-sm text-blue-700">{currentPlan.simDataSize}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-blue-900">
|
||||
{currentPlan.monthlyPrice ? formatCurrency(currentPlan.monthlyPrice) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">/month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="globalip"
|
||||
type="checkbox"
|
||||
checked={assignGlobalIp}
|
||||
onChange={e => setAssignGlobalIp(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">
|
||||
Assign global IP
|
||||
</label>
|
||||
</div>
|
||||
{/* Available Plans */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Select a New Plan</label>
|
||||
<div className="grid gap-3">
|
||||
{plans
|
||||
.filter(p => !p.isCurrentPlan)
|
||||
.map(plan => (
|
||||
<label
|
||||
key={plan.id}
|
||||
className={`relative flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedPlan?.id === plan.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="plan"
|
||||
value={plan.id}
|
||||
checked={selectedPlan?.id === plan.id}
|
||||
onChange={() => setSelectedPlan(plan)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 mr-4 flex items-center justify-center ${
|
||||
selectedPlan?.id === plan.id ? "border-blue-500" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selectedPlan?.id === plan.id && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{plan.name}</div>
|
||||
<div className="text-sm text-gray-500">{plan.simDataSize} • {plan.simPlanType}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{plan.monthlyPrice ? formatCurrency(plan.monthlyPrice) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">/month</div>
|
||||
</div>
|
||||
{selectedPlan?.id === plan.id && (
|
||||
<CheckCircleIcon className="absolute top-2 right-2 h-5 w-5 text-blue-500" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Schedule (optional)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledAt}
|
||||
onChange={e => setScheduledAt(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
{/* Global IP Option */}
|
||||
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
|
||||
<input
|
||||
id="globalip"
|
||||
type="checkbox"
|
||||
checked={assignGlobalIp}
|
||||
onChange={e => setAssignGlobalIp(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="globalip" className="ml-3 text-sm text-gray-700">
|
||||
Assign a global IP address (additional charges may apply)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Processing…" : "Submit Plan Change"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
{/* Info Box */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-yellow-900 mb-1">Important Notes</h3>
|
||||
<ul className="text-sm text-yellow-800 space-y-1">
|
||||
<li>• Plan changes take effect on the 1st of the following month</li>
|
||||
<li>• Requests must be made before the 25th of the current month</li>
|
||||
<li>• Your current data balance will be reset when the new plan activates</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedPlan}
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{loading ? "Processing…" : "Confirm Plan Change"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SubCard>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
311
apps/portal/src/features/subscriptions/views/SimReissue.tsx
Normal file
311
apps/portal/src/features/subscriptions/views/SimReissue.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { DevicePhoneMobileIcon, DeviceTabletIcon, CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService, type ReissueSimRequest } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
||||
|
||||
type SimType = "physical" | "esim";
|
||||
|
||||
export function SimReissueContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = params.id as string;
|
||||
const [simType, setSimType] = useState<SimType | null>(null);
|
||||
const [newEid, setNewEid] = useState("");
|
||||
const [currentEid, setCurrentEid] = useState<string | null>(null);
|
||||
const [simDetails, setSimDetails] = useState<SimDetails | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingDetails, setLoadingDetails] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const info = await simActionsService.getSimInfo(subscriptionId);
|
||||
if (info?.details) {
|
||||
setSimDetails(info.details);
|
||||
setCurrentEid(info.details.eid || null);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
||||
} finally {
|
||||
setLoadingDetails(false);
|
||||
}
|
||||
};
|
||||
void fetchDetails();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const isValidEid = () => {
|
||||
return /^\d{32}$/.test(newEid.trim());
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!simType) {
|
||||
setError("Please select a SIM type");
|
||||
return;
|
||||
}
|
||||
if (simType === "esim" && !isValidEid()) {
|
||||
setError("Please enter a valid 32-digit EID");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request: ReissueSimRequest = {
|
||||
simType,
|
||||
...(simType === "esim" && { newEid: newEid.trim() }),
|
||||
};
|
||||
await simActionsService.reissueSim(subscriptionId, request);
|
||||
|
||||
if (simType === "esim") {
|
||||
setMessage(
|
||||
"eSIM reissue request submitted successfully. You will receive instructions via email to download your new eSIM profile."
|
||||
);
|
||||
} else {
|
||||
setMessage(
|
||||
"Physical SIM reissue request submitted successfully. You will be contacted shortly with shipping details (typically 3-5 business days)."
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit reissue request");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<DevicePhoneMobileIcon />}
|
||||
title="Reissue SIM"
|
||||
description="Request a replacement SIM card"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SubCard>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Request SIM Reissue</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
If your SIM card is lost, damaged, or you need to switch between physical SIM and eSIM,
|
||||
you can request a replacement here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="mb-4">
|
||||
<AlertBanner variant="success" title="Request Submitted">
|
||||
{message}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<AlertBanner variant="error" title="Request Failed">
|
||||
{error}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingDetails ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-32 bg-gray-100 rounded-lg"></div>
|
||||
<div className="h-32 bg-gray-100 rounded-lg"></div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
||||
{/* Current SIM Info */}
|
||||
{simDetails && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-xs text-gray-500 font-medium uppercase mb-2">Current SIM</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Number:</span>{" "}
|
||||
<span className="font-medium">{simDetails.msisdn}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Type:</span>{" "}
|
||||
<span className="font-medium capitalize">{simDetails.simType}</span>
|
||||
</div>
|
||||
{simDetails.iccid && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">ICCID:</span>{" "}
|
||||
<span className="font-mono text-xs">{simDetails.iccid}</span>
|
||||
</div>
|
||||
)}
|
||||
{currentEid && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">Current EID:</span>{" "}
|
||||
<span className="font-mono text-xs">{currentEid}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIM Type Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Select Replacement SIM Type
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Physical SIM Option */}
|
||||
<label
|
||||
className={`relative flex flex-col items-center p-6 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
simType === "physical"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="simType"
|
||||
value="physical"
|
||||
checked={simType === "physical"}
|
||||
onChange={() => setSimType("physical")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<DeviceTabletIcon className="h-12 w-12 text-gray-600 mb-3" />
|
||||
<div className="text-lg font-medium text-gray-900">Physical SIM</div>
|
||||
<div className="text-sm text-gray-500 text-center mt-1">
|
||||
A new physical SIM card will be shipped to you
|
||||
</div>
|
||||
{simType === "physical" && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* eSIM Option */}
|
||||
<label
|
||||
className={`relative flex flex-col items-center p-6 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
simType === "esim"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="simType"
|
||||
value="esim"
|
||||
checked={simType === "esim"}
|
||||
onChange={() => setSimType("esim")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<CpuChipIcon className="h-12 w-12 text-gray-600 mb-3" />
|
||||
<div className="text-lg font-medium text-gray-900">eSIM</div>
|
||||
<div className="text-sm text-gray-500 text-center mt-1">
|
||||
Download your eSIM profile instantly
|
||||
</div>
|
||||
{simType === "esim" && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* eSIM EID Input */}
|
||||
{simType === "esim" && (
|
||||
<div className="space-y-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">
|
||||
New EID (eSIM Identifier)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEid}
|
||||
onChange={e => setNewEid(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="Enter your device's 32-digit EID"
|
||||
maxLength={32}
|
||||
className="w-full px-3 py-2 border border-blue-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
The EID is a 32-digit number unique to your device. You can find it in your
|
||||
device settings under "About" or "SIM status".
|
||||
</p>
|
||||
{newEid && !isValidEid() && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
Please enter exactly 32 digits ({newEid.length}/32)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentEid && (
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Current EID:</strong>{" "}
|
||||
<span className="font-mono text-xs">{currentEid}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Physical SIM Info */}
|
||||
{simType === "physical" && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-yellow-900 mb-2">Physical SIM Information</h3>
|
||||
<ul className="text-sm text-yellow-800 space-y-1">
|
||||
<li>• A new physical SIM card will be shipped to your registered address</li>
|
||||
<li>• Typical delivery time: 3-5 business days</li>
|
||||
<li>• You will receive an email with tracking information</li>
|
||||
<li>• The old SIM card will be deactivated once the new one is activated</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !simType || (simType === "esim" && !isValidEid())}
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{loading ? "Processing…" : "Submit Reissue Request"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SubCard>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimReissueContainer;
|
||||
|
||||
@ -72,5 +72,9 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/ssh2-sftp-client": "^9.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,6 +47,10 @@ export type {
|
||||
SimCardType,
|
||||
ActivationType,
|
||||
MnpData,
|
||||
// Enhanced request types
|
||||
SimCancelFullRequest,
|
||||
SimTopUpFullRequest,
|
||||
SimChangePlanFullRequest,
|
||||
// Activation types
|
||||
SimOrderActivationRequest,
|
||||
SimOrderActivationMnp,
|
||||
|
||||
@ -26,6 +26,7 @@ export const simDetailsSchema = z.object({
|
||||
networkType: z.string(),
|
||||
activatedAt: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
});
|
||||
|
||||
export const recentDayUsageSchema = z.object({
|
||||
@ -151,6 +152,36 @@ export const simReissueRequestSchema = z.object({
|
||||
.string()
|
||||
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
||||
.optional(),
|
||||
simType: z.enum(["physical", "esim"]).optional(),
|
||||
});
|
||||
|
||||
// Enhanced cancellation request with more details
|
||||
export const simCancelFullRequestSchema = z.object({
|
||||
cancellationMonth: z.string().regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||
confirmRead: z.boolean(),
|
||||
confirmCancel: z.boolean(),
|
||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||
comments: z.string().max(1000).optional(),
|
||||
}).refine(
|
||||
(data) => data.confirmRead === true && data.confirmCancel === true,
|
||||
{ message: "You must confirm both checkboxes to proceed" }
|
||||
);
|
||||
|
||||
// Top-up request with enhanced details for email
|
||||
export const simTopUpFullRequestSchema = z.object({
|
||||
quotaMb: z
|
||||
.number()
|
||||
.int()
|
||||
.min(100, "Quota must be at least 100MB")
|
||||
.max(51200, "Quota must be 50GB or less"),
|
||||
});
|
||||
|
||||
// Change plan request with Salesforce product info
|
||||
export const simChangePlanFullRequestSchema = z.object({
|
||||
newPlanCode: z.string().min(1, "New plan code is required"),
|
||||
newPlanSku: z.string().min(1, "New plan SKU is required"),
|
||||
newPlanName: z.string().optional(),
|
||||
assignGlobalIp: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const simMnpFormSchema = z.object({
|
||||
@ -334,6 +365,9 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
|
||||
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
||||
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
||||
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
|
||||
export type SimCancelFullRequest = z.infer<typeof simCancelFullRequestSchema>;
|
||||
export type SimTopUpFullRequest = z.infer<typeof simTopUpFullRequestSchema>;
|
||||
export type SimChangePlanFullRequest = z.infer<typeof simChangePlanFullRequestSchema>;
|
||||
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
|
||||
export type SimCardType = z.infer<typeof simCardTypeSchema>;
|
||||
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
|
||||
|
||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@ -7,6 +7,13 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@types/ssh2-sftp-client':
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.1
|
||||
@ -95,6 +102,9 @@ importers:
|
||||
'@sendgrid/mail':
|
||||
specifier: ^8.1.6
|
||||
version: 8.1.6
|
||||
'@types/ssh2-sftp-client':
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@ -167,6 +177,9 @@ importers:
|
||||
speakeasy:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@ -1827,6 +1840,9 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@24.3.1':
|
||||
resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==}
|
||||
|
||||
@ -1865,6 +1881,12 @@ packages:
|
||||
'@types/speakeasy@2.0.10':
|
||||
resolution: {integrity: sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==}
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.5':
|
||||
resolution: {integrity: sha512-cpUO6okDusnfLw2hnmaBiomlSchIWNVcCdpywLRsg/h9Q1TTiUSrzhkn5sJeeyTM8h6xRbZEZZjgWtUXFDogHg==}
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||
|
||||
'@types/stack-utils@2.0.3':
|
||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||
|
||||
@ -2245,6 +2267,9 @@ packages:
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
asn1@0.2.6:
|
||||
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
@ -2315,6 +2340,9 @@ packages:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
@ -2357,6 +2385,10 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buildcheck@0.0.7:
|
||||
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
bullmq@5.58.5:
|
||||
resolution: {integrity: sha512-0A6Qjxdn8j7aOcxfRZY798vO/aMuwvoZwfE6a9EOXHb1pzpBVAogsc/OfRWeUf+5wMBoYB5nthstnJo/zrQOeQ==}
|
||||
|
||||
@ -2595,6 +2627,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
cpu-features@0.0.10:
|
||||
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
@ -4089,6 +4125,9 @@ packages:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
nan@2.23.1:
|
||||
resolution: {integrity: sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@ -4782,6 +4821,14 @@ packages:
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
ssh2-sftp-client@12.0.1:
|
||||
resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==}
|
||||
engines: {node: '>=18.20.4'}
|
||||
|
||||
ssh2@1.17.0:
|
||||
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@ -5097,6 +5144,9 @@ packages:
|
||||
tw-animate-css@1.3.8:
|
||||
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
|
||||
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@ -5182,6 +5232,9 @@ packages:
|
||||
underscore@1.13.7:
|
||||
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@7.10.0:
|
||||
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
|
||||
|
||||
@ -6869,6 +6922,10 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@24.3.1':
|
||||
dependencies:
|
||||
undici-types: 7.10.0
|
||||
@ -6920,6 +6977,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.3.1
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.5':
|
||||
dependencies:
|
||||
'@types/ssh2': 1.15.5
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
|
||||
'@types/stack-utils@2.0.3': {}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
@ -7344,6 +7409,10 @@ snapshots:
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
asn1@0.2.6:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
@ -7434,6 +7503,10 @@ snapshots:
|
||||
|
||||
base64url@3.0.1: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
bcrypt@6.0.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.5.0
|
||||
@ -7496,6 +7569,9 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buildcheck@0.0.7:
|
||||
optional: true
|
||||
|
||||
bullmq@5.58.5:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
@ -7717,6 +7793,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
cpu-features@0.0.10:
|
||||
dependencies:
|
||||
buildcheck: 0.0.7
|
||||
nan: 2.23.1
|
||||
optional: true
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cron-parser@4.9.0:
|
||||
@ -9571,6 +9653,9 @@ snapshots:
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nan@2.23.1:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-postinstall@0.3.3: {}
|
||||
@ -10341,6 +10426,19 @@ snapshots:
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
ssh2-sftp-client@12.0.1:
|
||||
dependencies:
|
||||
concat-stream: 2.0.0
|
||||
ssh2: 1.17.0
|
||||
|
||||
ssh2@1.17.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
optionalDependencies:
|
||||
cpu-features: 0.0.10
|
||||
nan: 2.23.1
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
@ -10673,6 +10771,8 @@ snapshots:
|
||||
|
||||
tw-animate-css@1.3.8: {}
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@ -10767,6 +10867,8 @@ snapshots:
|
||||
|
||||
underscore@1.13.7: {}
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user