From f49e5d7574a0408d16f13d93d7caeb1409971761 Mon Sep 17 00:00:00 2001 From: tema Date: Sat, 29 Nov 2025 16:42:08 +0900 Subject: [PATCH] Enhance SIM management features and introduce new cancellation and plan change flows - Added new models and request types for enhanced SIM cancellation and plan change functionalities. - Implemented full cancellation flow with email notifications and confirmation handling. - Introduced enhanced plan change request with Salesforce product mapping and scheduling. - Updated UI components for better user experience during SIM management actions. - Improved error handling and validation for cancellation and plan change requests. --- .cursor/rules/portal-rule.mdc | 9 + apps/bff/package.json | 4 +- apps/bff/prisma/schema.prisma | 75 ++++ .../services/freebit-operations.service.ts | 66 +++- .../services/freebit-orchestrator.service.ts | 10 +- .../integrations/sftp/sftp-client.service.ts | 181 +++++++++ .../services/esim-management.service.ts | 139 ++++++- .../services/sim-api-notification.service.ts | 179 +++++++++ .../services/sim-cancellation.service.ts | 253 +++++++++++- .../services/sim-plan.service.ts | 169 +++++++- .../services/sim-topup.service.ts | 61 +++ .../sim-management/sim-management.module.ts | 5 + .../subscriptions/subscriptions.controller.ts | 96 ++++- .../subscriptions/[id]/sim/reissue/page.tsx | 6 +- .../components/SimManagementSection.tsx | 21 +- .../services/sim-actions.service.ts | 82 +++- .../subscriptions/views/SimCancel.tsx | 372 ++++++++++-------- .../subscriptions/views/SimChangePlan.tsx | 235 +++++++---- .../subscriptions/views/SimReissue.tsx | 311 +++++++++++++++ package.json | 4 + packages/domain/sim/index.ts | 4 + packages/domain/sim/schema.ts | 34 ++ pnpm-lock.yaml | 102 +++++ 23 files changed, 2147 insertions(+), 271 deletions(-) create mode 100644 .cursor/rules/portal-rule.mdc create mode 100644 apps/bff/src/integrations/sftp/sftp-client.service.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts create mode 100644 apps/portal/src/features/subscriptions/views/SimReissue.tsx diff --git a/.cursor/rules/portal-rule.mdc b/.cursor/rules/portal-rule.mdc new file mode 100644 index 00000000..4c23b256 --- /dev/null +++ b/.cursor/rules/portal-rule.mdc @@ -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. \ No newline at end of file diff --git a/apps/bff/package.json b/apps/bff/package.json index 1e7af258..e896625b 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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" }, diff --git a/apps/bff/prisma/schema.prisma b/apps/bff/prisma/schema.prisma index 0ac06053..f3fe1b7d 100644 --- a/apps/bff/prisma/schema.prisma +++ b/apps/bff/prisma/schema.prisma @@ -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") +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts index cadafd87..bd18b917 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts @@ -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 { 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( @@ -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 { + try { + const request: Omit = { + 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( + "/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}`); } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts b/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts index 37c52b86..f5ca2351 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts @@ -79,13 +79,21 @@ export class FreebitOrchestratorService { } /** - * Cancel SIM service + * Cancel SIM service (plan cancellation - PA05-04) */ async cancelSim(account: string, scheduledAt?: string): Promise { 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 { + const normalizedAccount = this.mapper.normalizeAccount(account); + return this.operations.cancelAccount(normalizedAccount, runDate); + } + /** * Reissue eSIM profile (simple) */ diff --git a/apps/bff/src/integrations/sftp/sftp-client.service.ts b/apps/bff/src/integrations/sftp/sftp-client.service.ts new file mode 100644 index 00000000..d73197c4 --- /dev/null +++ b/apps/bff/src/integrations/sftp/sftp-client.service.ts @@ -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("SFTP_HOST") || "fs.mvno.net", + port: this.configService.get("SFTP_PORT") || 22, + username: this.configService.get("SFTP_USERNAME") || "PASI", + password: this.configService.get("SFTP_PASSWORD") || "", + }; + } + + async onModuleDestroy() { + await this.disconnect(); + } + + private async connect(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const fileName = this.getTalkDetailFileName(yearMonth); + return this.downloadFileAsString(`/${fileName}`); + } + + /** + * Download SMS detail CSV for a specific month + */ + async downloadSmsDetail(yearMonth: string): Promise { + const fileName = this.getSmsDetailFileName(yearMonth); + return this.downloadFileAsString(`/${fileName}`); + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts index 8a33e05a..b5a45122 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts @@ -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("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 { + 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, + }); + } + } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts new file mode 100644 index 00000000..bb2e269c --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts @@ -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; + json?: Record | string; + result: Record | 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 { + 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 { + 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"}`; + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 5e5daf80..66cc5c95 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -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("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 { + 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 { + 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 + ); + } + } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index 569e675c..c1eebc17 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -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 = { + "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 = 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("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 { + 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) */ diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index ed5d5eea..9c32b826 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -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("WHMCS_BASE_URL") || "https://accounts.asolutions.co.jp/includes/api.php"; + } + + private get freebitBaseUrl(): string { + return this.configService.get("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, diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index f1805f4f..af0e7c74 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -18,12 +18,14 @@ 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 { CatalogModule } from "@bff/modules/catalog/catalog.module"; @Module({ imports: [ @@ -32,6 +34,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service"; SalesforceModule, MappingsModule, EmailModule, + CatalogModule, ], providers: [ // Core services that the SIM services depend on @@ -41,6 +44,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service"; // SIM management services SimValidationService, SimNotificationService, + SimApiNotificationService, SimVoiceOptionsService, SimDetailsService, SimUsageService, @@ -73,6 +77,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service"; EsimManagementService, SimValidationService, SimNotificationService, + SimApiNotificationService, SimBillingService, SimScheduleService, SimActionRunnerService, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 01de4e92..27d06a0a 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -32,13 +32,20 @@ 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"; const subscriptionInvoiceQuerySchema = createPaginationSchema({ defaultLimit: 10, @@ -52,7 +59,10 @@ 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 ) {} @Get() @@ -228,4 +238,88 @@ 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 { + 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 { + 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 { + 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." }; + } + } } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx index 67e08591..e99470f2 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx +++ b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx @@ -1,3 +1,5 @@ -export default function Page() { - return null; +import SimReissueContainer from "@/features/subscriptions/views/SimReissue"; + +export default function SimReissuePage() { + return ; } diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 6d130672..15362b1c 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { DevicePhoneMobileIcon, ExclamationTriangleIcon, @@ -49,10 +50,17 @@ interface SimInfo { } export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) { + const router = useRouter(); const [simInfo, setSimInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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`); + // Fetch subscription data const { data: subscription } = useSubscription(subscriptionId); @@ -234,7 +242,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro {/* Top Up Data Button */}
@@ -185,8 +203,8 @@ export function SimCancelContainer() { {step === 2 && (
-
- +
+ 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 - + + 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. - + + 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. - + + 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.
-
- setAcceptTerms(e.target.checked)} - /> - -
-
- setConfirmMonthEnd(e.target.checked)} - disabled={!cancelMonth} - /> - + +
+
+ setAcceptTerms(e.target.checked)} + className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5" + /> + +
+ +
+ setConfirmMonthEnd(e.target.checked)} + disabled={!selectedMonth} + className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5" + /> + +
+
@@ -262,65 +290,93 @@ export function SimCancelContainer() { {step === 3 && (
- {registeredEmail && ( -
- Your registered email address is:{" "} - {registeredEmail} + {/* Voice SIM Notice */} +
+
+ For Voice-enabled SIM subscriptions:
- )} -
+
+ 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. +
+
+ If you would like to make the payment with a different credit card, please contact + Assist Solutions at info@asolutions.co.jp +
+
+ + {/* Registered Email */} +
+ Your registered email address is:{" "} + {preview?.customerEmail || "—"} +
+
You will receive a cancellation confirmation email. If you would like to receive this email on a different address, please enter the address below.
+ + {/* Alternative Email */}
- + 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" />
- + 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" />
-
- -