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..abe34ab0 --- /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(`/home/PASI/${fileName}`); + } + + /** + * Download SMS detail CSV for a specific month + */ + async downloadSmsDetail(yearMonth: string): Promise { + const fileName = this.getSmsDetailFileName(yearMonth); + return this.downloadFileAsString(`/home/PASI/${fileName}`); + } +} + diff --git a/apps/bff/src/integrations/sftp/sftp.module.ts b/apps/bff/src/integrations/sftp/sftp.module.ts new file mode 100644 index 00000000..b9ea4dc8 --- /dev/null +++ b/apps/bff/src/integrations/sftp/sftp.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { SftpClientService } from "./sftp-client.service"; + +@Module({ + providers: [SftpClientService], + exports: [SftpClientService], +}) +export class SftpModule {} + 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-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts new file mode 100644 index 00000000..710857fe --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts @@ -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 { + // 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 { + // 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 { + // 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 { + 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 { + 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}`; + } +} + 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..2e06920b 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 @@ -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 ], }) diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 01de4e92..84af66e6 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -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 { - 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 { - 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 { + 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 { + 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 { + 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." }; + } + } + + // ==================== 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 }; + } } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx new file mode 100644 index 00000000..efa22ee9 --- /dev/null +++ b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx @@ -0,0 +1,6 @@ +import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory"; + +export default function SimCallHistoryPage() { + return ; +} + 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 b231ea5d..62b6a395 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -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(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`); + 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 */}
+ + Page {page} of {totalPages} + + +
+ ); +} + +export function SimCallHistoryContainer() { + const params = useParams(); + const subscriptionId = params.id as string; + + const [activeTab, setActiveTab] = useState("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(null); + + // Data states + const [domesticData, setDomesticData] = useState(null); + const [internationalData, setInternationalData] = useState(null); + const [smsData, setSmsData] = useState(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 ( + } + title="Call & SMS History" + description="View your call and SMS records" + > +
+
+ + ← Back to SIM Management + +
+ + + {/* Current Month Display */} +
+
+ + Showing data for: September 2025 + +
+

+ Call/SMS history is available approximately 2 months after the calls are made. +

+
+ + {/* Tabs */} +
+ +
+ + {/* Error */} + {error && ( +
+ + {error} + +
+ )} + + {/* Loading */} + {loading && ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ )} + + {/* Domestic Calls Table */} + {!loading && activeTab === "domestic" && domesticData && ( + <> + {domesticData.calls.length === 0 ? ( +
+ No domestic calls found for this month +
+ ) : ( +
+ + + + + + + + + + + + {domesticData.calls.map(call => ( + + + + + + + + ))} + +
+ Date + + Time + + Called To + + Call Length + + Call Charge (¥) +
+ {call.date} + + {call.time} + + {call.calledTo} + + {call.callLength} + + {formatCurrency(call.callCharge)} +
+
+ )} + + + )} + + {/* International Calls Table */} + {!loading && activeTab === "international" && internationalData && ( + <> + {internationalData.calls.length === 0 ? ( +
+ No international calls found for this month +
+ ) : ( +
+ + + + + + + + + + + + + {internationalData.calls.map(call => ( + + + + + + + + + ))} + +
+ Date + + Start Time + + Stop Time + + Country + + Called To + + Call Charge (¥) +
+ {call.date} + + {call.startTime} + + {call.stopTime || "—"} + + {call.country || "—"} + + {call.calledTo} + + {formatCurrency(call.callCharge)} +
+
+ )} + + + )} + + {/* SMS Table */} + {!loading && activeTab === "sms" && smsData && ( + <> + {smsData.messages.length === 0 ? ( +
+ No SMS found for this month +
+ ) : ( +
+ + + + + + + + + + + {smsData.messages.map(msg => ( + + + + + + + ))} + +
+ Date + + Time + + Sent To + + Type +
+ {msg.date} + + {msg.time} + + {msg.sentTo} + + + {msg.type} + +
+
+ )} + + + )} + + {/* Info Note */} +
+

Important Notes

+
    +
  • • Call/SMS history is updated approximately 2 months after the calls/messages are made
  • +
  • • The history shows approximately 3 months of records
  • +
  • • Call charges shown are based on the carrier billing data
  • +
+
+
+
+
+ ); +} + +export default SimCallHistoryContainer; + diff --git a/apps/portal/src/features/subscriptions/views/SimCancel.tsx b/apps/portal/src/features/subscriptions/views/SimCancel.tsx index dd38f430..511af9b6 100644 --- a/apps/portal/src/features/subscriptions/views/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCancel.tsx @@ -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 ( -
-
{title}
-
{children}
+
+
{title}
+
{children}
); } @@ -34,79 +32,63 @@ export function SimCancelContainer() { const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); - const [details, setDetails] = useState(null); + const [preview, setPreview] = useState(null); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [acceptTerms, setAcceptTerms] = useState(false); const [confirmMonthEnd, setConfirmMonthEnd] = useState(false); - const [cancelMonth, setCancelMonth] = useState(""); - const [email, setEmail] = useState(""); - const [email2, setEmail2] = useState(""); - const [notes, setNotes] = useState(""); - const [registeredEmail, setRegisteredEmail] = useState(null); + const [selectedMonth, setSelectedMonth] = useState(""); + const [alternativeEmail, setAlternativeEmail] = useState(""); + const [alternativeEmail2, setAlternativeEmail2] = useState(""); + const [comments, setComments] = useState(""); + 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 ( +
+
+
+
+
+
+ ); + } + return (
@@ -123,14 +116,24 @@ export function SimCancelContainer() { > ← Back to SIM Management -
Step {step} of 3
+
+ {[1, 2, 3].map(s => ( +
+ ))} +
+
Step {step} of 3
{error && ( -
{error}
+
{error}
)} {message && ( -
+
{message}
)} @@ -138,44 +141,59 @@ export function SimCancelContainer() {

Cancel SIM

- 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.

+ {/* Minimum Contract Warning */} + {preview?.isWithinMinimumTerm && ( +
+
Minimum Contract Term Warning
+
+ Your subscription is still within the minimum contract period (ends {preview.minimumContractEndDate}). + Early cancellation may result in additional charges for the remaining months. +
+
+ )} + {step === 1 && (
-
- - -
- - -

- Cancellation takes effect at the start of the selected month. -

-
+ {/* SIM Info */} +
+ + +
+ + {/* Month Selection */} +
+ + +

+ Your subscription will be cancelled at the end of the selected month. +

+
+
@@ -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" />
-
- -