From 6096c156592512a204d4be4563add3b324463d26 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 5 Jan 2026 16:32:45 +0900 Subject: [PATCH] Refactor Salesforce Opportunity Integration for SIM and Internet Cancellations - Updated opportunity field mappings to replace deprecated fields with new ones for SIM and Internet cancellations, enhancing clarity and consistency. - Introduced separate data structures for Internet and SIM cancellation data, improving type safety and validation. - Refactored SalesforceOpportunityService to handle updates for both Internet and SIM cancellations, ensuring accurate data handling. - Enhanced cancellation query fields to support new SIM cancellation requirements, improving the overall cancellation process. - Cleaned up the portal integration to reflect changes in opportunity source fields, promoting better data integrity and tracking. --- .../config/opportunity-field-map.ts | 67 ++- .../salesforce/constants/field-maps.ts | 8 +- .../salesforce-opportunity.service.ts | 218 ++++++++- .../services/internet-cancellation.service.ts | 22 +- .../services/sim-api-notification.service.ts | 2 - .../services/sim-cancellation.service.ts | 123 ++++- .../subscriptions/components/index.ts | 1 + .../subscriptions/views/InternetCancel.tsx | 418 ++++------------ .../subscriptions/views/SimCancel.tsx | 458 ++++-------------- ...TY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md | 6 +- .../salesforce/opportunity-lifecycle.md | 155 ++++-- docs/integrations/salesforce/requirements.md | 34 +- packages/domain/opportunity/contract.ts | 36 +- packages/domain/opportunity/index.ts | 9 +- packages/domain/sim/schema.ts | 1 - packages/domain/subscriptions/schema.ts | 1 - 16 files changed, 722 insertions(+), 837 deletions(-) diff --git a/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts b/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts index 0cb9a287..c885c0b8 100644 --- a/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts +++ b/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts @@ -59,16 +59,26 @@ export const OPPORTUNITY_EXISTING_CUSTOM_FIELDS = { /** Application process stage (INTRO-1, N/A, etc.) */ applicationStage: "Application_Stage__c", - // ---- Cancellation Fields (existing) ---- + // ---- Internet Cancellation Fields (existing) ---- - /** Scheduled cancellation date/time (end of month) */ + /** Scheduled cancellation date/time (end of month) - for Internet */ scheduledCancellationDate: "ScheduledCancellationDateAndTime__c", - /** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) */ + /** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) - for Internet */ cancellationNotice: "CancellationNotice__c", - /** Line return status for rental equipment */ + /** Line return status for rental equipment (ONU/router) - for Internet */ lineReturnStatus: "LineReturn__c", + + // ---- SIM Cancellation Fields (existing) ---- + + /** SIM Cancellation Form status: 有 (received), 未 (not yet), 不要 (not required) */ + simCancellationNotice: "SIMCancellationNotice__c", + + /** SIM Scheduled cancellation date/time (end of month) */ + simScheduledCancellationDate: "SIMScheduledCancellationDateAndTime__c", + + // NOTE: SIM cancellation comments go on the Cancellation Case, same as Internet } as const; // ============================================================================ @@ -85,27 +95,25 @@ export const OPPORTUNITY_COMMODITY_FIELD = { } as const; // ============================================================================ -// New Custom Fields (to be created in Salesforce) +// Portal Integration Custom Fields (Existing in Salesforce) // ============================================================================ /** - * New custom fields to be created in Salesforce. + * Portal integration custom fields that exist in Salesforce. * * NOTE: * - CommodityType already exists - no need to create Product_Type__c * - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp) * - Orders link TO Opportunity via Order.OpportunityId (standard field) * - Alternative email and cancellation comments go on Case, not Opportunity - * - * TODO: Confirm with Salesforce admin and update API names after creation */ -export const OPPORTUNITY_NEW_CUSTOM_FIELDS = { - // ---- Source Field (to be created) ---- +export const OPPORTUNITY_PORTAL_INTEGRATION_FIELDS = { + // ---- Source Field (existing) ---- /** Source of the Opportunity creation */ - source: "Portal_Source__c", + source: "Opportunity_Source__c", - // ---- Integration Fields (to be created) ---- + // ---- Integration Fields (existing) ---- /** WHMCS Service ID (populated after provisioning) */ whmcsServiceId: "WHMCS_Service_ID__c", @@ -125,7 +133,7 @@ export const OPPORTUNITY_FIELD_MAP = { ...OPPORTUNITY_STANDARD_FIELDS, ...OPPORTUNITY_EXISTING_CUSTOM_FIELDS, ...OPPORTUNITY_COMMODITY_FIELD, - ...OPPORTUNITY_NEW_CUSTOM_FIELDS, + ...OPPORTUNITY_PORTAL_INTEGRATION_FIELDS, } as const; export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP; @@ -156,17 +164,20 @@ export const OPPORTUNITY_MATCH_QUERY_FIELDS = [ export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [ ...OPPORTUNITY_MATCH_QUERY_FIELDS, OPPORTUNITY_FIELD_MAP.whmcsServiceId, + // Internet cancellation fields OPPORTUNITY_FIELD_MAP.scheduledCancellationDate, OPPORTUNITY_FIELD_MAP.cancellationNotice, OPPORTUNITY_FIELD_MAP.lineReturnStatus, + // SIM cancellation fields + OPPORTUNITY_FIELD_MAP.simCancellationNotice, + OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate, OPPORTUNITY_FIELD_MAP.lastModifiedDate, - // NOTE: Cancellation comments and alternative email are on the Cancellation Case ] as const; /** - * Fields to select for cancellation status display + * Fields to select for Internet cancellation status display */ -export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [ +export const OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS = [ OPPORTUNITY_FIELD_MAP.id, OPPORTUNITY_FIELD_MAP.stage, OPPORTUNITY_FIELD_MAP.commodityType, @@ -176,6 +187,23 @@ export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [ OPPORTUNITY_FIELD_MAP.whmcsServiceId, ] as const; +/** + * Fields to select for SIM cancellation status display + */ +export const OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS = [ + OPPORTUNITY_FIELD_MAP.id, + OPPORTUNITY_FIELD_MAP.stage, + OPPORTUNITY_FIELD_MAP.commodityType, + OPPORTUNITY_FIELD_MAP.simCancellationNotice, + OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate, + OPPORTUNITY_FIELD_MAP.whmcsServiceId, +] as const; + +/** + * @deprecated Use OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS or OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS + */ +export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS; + // ============================================================================ // Stage Picklist Reference (Existing Values) // ============================================================================ @@ -353,14 +381,13 @@ export const COMMODITY_TYPE_REFERENCE = { } as const; // ============================================================================ -// New Picklist Values (to be created in Salesforce) +// Opportunity Source Picklist Reference (Existing Values) // ============================================================================ /** - * Source picklist values for Portal_Source__c field - * (needs to be created in Salesforce) + * Source picklist values for Opportunity_Source__c field (existing in Salesforce) */ -export const PORTAL_SOURCE_PICKLIST = [ +export const OPPORTUNITY_SOURCE_PICKLIST = [ { value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" }, { value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" }, { value: "Portal - Order Placement", label: "Portal - Order Placement" }, diff --git a/apps/bff/src/integrations/salesforce/constants/field-maps.ts b/apps/bff/src/integrations/salesforce/constants/field-maps.ts index 5a1a2af6..31f621a1 100644 --- a/apps/bff/src/integrations/salesforce/constants/field-maps.ts +++ b/apps/bff/src/integrations/salesforce/constants/field-maps.ts @@ -80,13 +80,17 @@ export const OPPORTUNITY_FIELDS = { applicationStage: "Application_Stage__c", // Portal integration - portalSource: "Portal_Source__c", + opportunitySource: "Opportunity_Source__c", whmcsServiceId: "WHMCS_Service_ID__c", - // Cancellation + // Internet Cancellation cancellationNotice: "CancellationNotice__c", scheduledCancellationDate: "ScheduledCancellationDateAndTime__c", lineReturn: "LineReturn__c", + + // SIM Cancellation + simCancellationNotice: "SIMCancellationNotice__c", + simScheduledCancellationDate: "SIMScheduledCancellationDateAndTime__c", } as const; export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS; diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts index afdc8d77..542da81f 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -45,7 +45,8 @@ import { OPPORTUNITY_FIELD_MAP, OPPORTUNITY_MATCH_QUERY_FIELDS, OPPORTUNITY_DETAIL_QUERY_FIELDS, - OPPORTUNITY_CANCELLATION_QUERY_FIELDS, + OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS, + OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS, } from "../config/opportunity-field-map.js"; // ============================================================================ @@ -68,18 +69,41 @@ interface SalesforceOpportunityRecord { // Existing custom fields Application_Stage__c?: string; CommodityType?: string; // Existing product type field + // Internet cancellation fields ScheduledCancellationDateAndTime__c?: string; CancellationNotice__c?: string; LineReturn__c?: string; - // New custom fields (to be created) - Portal_Source__c?: string; + // SIM cancellation fields + SIMCancellationNotice__c?: string; + SIMScheduledCancellationDateAndTime__c?: string; + // Portal integration custom fields (existing) + Opportunity_Source__c?: string; WHMCS_Service_ID__c?: number; // Note: Cases and Orders link TO Opportunity via their OpportunityId field - // Cancellation comments and alternative email are on the Cancellation Case // Relationship fields Account?: { Name?: string }; } +type InternetCancellationOpportunityDataInput = Pick< + CancellationOpportunityData, + "scheduledCancellationDate" | "cancellationNotice" | "lineReturnStatus" +>; + +type SimCancellationOpportunityDataInput = Pick< + CancellationOpportunityData, + "scheduledCancellationDate" | "cancellationNotice" +>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function requireStringField(record: Record, field: string): string { + const value = record[field]; + if (typeof value === "string") return value; + throw new Error(`Invalid ${field}`); +} + // ============================================================================ // Service // ============================================================================ @@ -234,7 +258,7 @@ export class SalesforceOpportunityService { } /** - * Update Opportunity with cancellation data from form submission + * Update Opportunity with Internet cancellation data from form submission * * Sets: * - Stage to △Cancelling @@ -246,26 +270,37 @@ export class SalesforceOpportunityService { * The Case is created separately and linked to this Opportunity via Case.OpportunityId. * * @param opportunityId - Salesforce Opportunity ID - * @param data - Cancellation data (dates and status flags) + * @param data - Internet cancellation data (dates and status flags) */ - async updateCancellationData( + async updateInternetCancellationData( opportunityId: string, - data: CancellationOpportunityData + data: InternetCancellationOpportunityDataInput ): Promise { const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - this.logger.log("Updating Opportunity with cancellation data", { + const safeData = (() => { + const unknownData: unknown = data; + if (!isRecord(unknownData)) throw new Error("Invalid cancellation data"); + + return { + scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), + cancellationNotice: requireStringField(unknownData, "cancellationNotice"), + lineReturnStatus: requireStringField(unknownData, "lineReturnStatus"), + }; + })(); + + this.logger.log("Updating Opportunity with Internet cancellation data", { opportunityId: safeOppId, - scheduledDate: data.scheduledCancellationDate, - cancellationNotice: data.cancellationNotice, + scheduledDate: safeData.scheduledCancellationDate, + cancellationNotice: safeData.cancellationNotice, }); const payload: Record = { Id: safeOppId, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, - [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate, - [OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice, - [OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.lineReturnStatus, + [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate, + [OPPORTUNITY_FIELD_MAP.cancellationNotice]: safeData.cancellationNotice, + [OPPORTUNITY_FIELD_MAP.lineReturnStatus]: safeData.lineReturnStatus, }; try { @@ -276,12 +311,12 @@ export class SalesforceOpportunityService { await updateMethod(payload as Record & { Id: string }); - this.logger.log("Opportunity cancellation data updated successfully", { + this.logger.log("Opportunity Internet cancellation data updated successfully", { opportunityId: safeOppId, - scheduledDate: data.scheduledCancellationDate, + scheduledDate: safeData.scheduledCancellationDate, }); } catch (error) { - this.logger.error("Failed to update Opportunity cancellation data", { + this.logger.error("Failed to update Opportunity Internet cancellation data", { error: extractErrorMessage(error), opportunityId: safeOppId, }); @@ -289,6 +324,79 @@ export class SalesforceOpportunityService { } } + /** + * Update Opportunity with SIM cancellation data from form submission + * + * Sets: + * - Stage to △Cancelling + * - SIMScheduledCancellationDateAndTime__c + * - SIMCancellationNotice__c to 有 (received) + * + * NOTE: Customer comments go on the Cancellation Case, not Opportunity (same as Internet). + * + * @param opportunityId - Salesforce Opportunity ID + * @param data - SIM cancellation data (dates and status flags) + */ + async updateSimCancellationData( + opportunityId: string, + data: SimCancellationOpportunityDataInput + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + const safeData = (() => { + const unknownData: unknown = data; + if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data"); + + return { + scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), + cancellationNotice: requireStringField(unknownData, "cancellationNotice"), + }; + })(); + + this.logger.log("Updating Opportunity with SIM cancellation data", { + opportunityId: safeOppId, + scheduledDate: safeData.scheduledCancellationDate, + cancellationNotice: safeData.cancellationNotice, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, + [OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate, + [OPPORTUNITY_FIELD_MAP.simCancellationNotice]: safeData.cancellationNotice, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity SIM cancellation data updated successfully", { + opportunityId: safeOppId, + scheduledDate: safeData.scheduledCancellationDate, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity SIM cancellation data", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + throw new Error("Failed to update SIM cancellation information"); + } + } + + /** + * @deprecated Use updateInternetCancellationData or updateSimCancellationData + */ + async updateCancellationData( + opportunityId: string, + data: CancellationOpportunityData + ): Promise { + return this.updateInternetCancellationData(opportunityId, data); + } + // ========================================================================== // Lookup Operations // ========================================================================== @@ -506,7 +614,13 @@ export class SalesforceOpportunityService { * @param whmcsServiceId - WHMCS Service ID * @returns Cancellation status details or null */ - async getCancellationStatus(whmcsServiceId: number): Promise<{ + /** + * Get Internet cancellation status for display in portal + * + * @param whmcsServiceId - WHMCS Service ID + * @returns Internet cancellation status details or null + */ + async getInternetCancellationStatus(whmcsServiceId: number): Promise<{ stage: OpportunityStageValue; isPending: boolean; isComplete: boolean; @@ -514,7 +628,7 @@ export class SalesforceOpportunityService { rentalReturnStatus?: LineReturnStatusValue; } | null> { const soql = ` - SELECT ${OPPORTUNITY_CANCELLATION_QUERY_FIELDS.join(", ")} + SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")} FROM Opportunity WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} ORDER BY CreatedDate DESC @@ -523,7 +637,7 @@ export class SalesforceOpportunityService { try { const result = (await this.sf.query(soql, { - label: "opportunity:getCancellationStatus", + label: "opportunity:getInternetCancellationStatus", })) as SalesforceResponse; const record = result.records?.[0]; @@ -541,7 +655,7 @@ export class SalesforceOpportunityService { rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, }; } catch (error) { - this.logger.error("Failed to get cancellation status", { + this.logger.error("Failed to get Internet cancellation status", { error: extractErrorMessage(error), whmcsServiceId, }); @@ -549,6 +663,66 @@ export class SalesforceOpportunityService { } } + /** + * Get SIM cancellation status for display in portal + * + * @param whmcsServiceId - WHMCS Service ID + * @returns SIM cancellation status details or null + */ + async getSimCancellationStatus(whmcsServiceId: number): Promise<{ + stage: OpportunityStageValue; + isPending: boolean; + isComplete: boolean; + scheduledEndDate?: string; + } | null> { + const soql = ` + SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getSimCancellationStatus", + })) as SalesforceResponse; + + const record = result.records?.[0]; + if (!record) return null; + + const stage = record.StageName as OpportunityStageValue; + const isPending = stage === OPPORTUNITY_STAGE.CANCELLING; + const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED; + + return { + stage, + isPending, + isComplete, + scheduledEndDate: record.SIMScheduledCancellationDateAndTime__c, + }; + } catch (error) { + this.logger.error("Failed to get SIM cancellation status", { + error: extractErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + /** + * @deprecated Use getInternetCancellationStatus or getSimCancellationStatus + */ + async getCancellationStatus(whmcsServiceId: number): Promise<{ + stage: OpportunityStageValue; + isPending: boolean; + isComplete: boolean; + scheduledEndDate?: string; + rentalReturnStatus?: LineReturnStatusValue; + } | null> { + return this.getInternetCancellationStatus(whmcsServiceId); + } + // ========================================================================== // Lifecycle Helpers // ========================================================================== @@ -724,7 +898,7 @@ export class SalesforceOpportunityService { closeDate: record.CloseDate, commodityType, productType: productType ?? undefined, - source: record.Portal_Source__c as OpportunitySourceValue | undefined, + source: record.Opportunity_Source__c as OpportunitySourceValue | undefined, applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined, isClosed: record.IsClosed, // Note: Related Cases and Orders are queried separately via their OpportunityId field diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index f871a2aa..61e6cbb3 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -27,7 +27,7 @@ import type { InternetCancelRequest, } from "@customer-portal/domain/subscriptions"; import { - type CancellationOpportunityData, + type InternetCancellationOpportunityData, CANCELLATION_NOTICE, LINE_RETURN_STATUS, } from "@customer-portal/domain/opportunity"; @@ -237,10 +237,6 @@ export class InternetCancellationService { ``, ]; - if (request.alternativeEmail) { - descriptionLines.push(`Alternative Contact Email: ${request.alternativeEmail}`); - } - if (request.comments) { descriptionLines.push(``, `Customer Comments:`, request.comments); } @@ -282,13 +278,16 @@ export class InternetCancellationService { // Update Opportunity if found if (opportunityId) { try { - const cancellationData: CancellationOpportunityData = { + const cancellationData: InternetCancellationOpportunityData = { scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`, cancellationNotice: CANCELLATION_NOTICE.RECEIVED, lineReturnStatus: LINE_RETURN_STATUS.NOT_YET, }; - await this.opportunityService.updateCancellationData(opportunityId, cancellationData); + await this.opportunityService.updateInternetCancellationData( + opportunityId, + cancellationData + ); this.logger.log("Opportunity updated with cancellation data", { opportunityId, @@ -327,15 +326,6 @@ Email: info@asolutions.co.jp`; subject: confirmationSubject, text: confirmationBody, }); - - // Send to alternative email if provided - if (request.alternativeEmail && request.alternativeEmail !== customerEmail) { - await this.emailService.sendEmail({ - to: request.alternativeEmail, - subject: confirmationSubject, - text: confirmationBody, - }); - } } catch (error) { // Log but don't fail - Case was already created this.logger.error("Failed to send cancellation confirmation email", { 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 index 76c80770..e215044c 100644 --- 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 @@ -154,7 +154,6 @@ Email: ${ADMIN_EMAIL}`; serialNumber?: string; cancellationMonth: string; registeredEmail: string; - otherEmail?: string; comments?: string; }): string { return `The following SONIXNET SIM cancellation has been requested. @@ -164,7 +163,6 @@ SIM #: ${params.simNumber} Serial #: ${params.serialNumber || "N/A"} Cancellation month: ${params.cancellationMonth} Registered email address: ${params.registeredEmail} -Other email address: ${params.otherEmail || "N/A"} Comments: ${params.comments || "N/A"}`; } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 109152eb..9d3b5393 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 @@ -4,6 +4,8 @@ import { ConfigService } from "@nestjs/config"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import type { SimCancelRequest, @@ -11,6 +13,8 @@ import type { SimCancellationMonth, SimCancellationPreview, } from "@customer-portal/domain/sim"; +import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; +import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity"; import { SimScheduleService } from "./sim-schedule.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimApiNotificationService } from "./sim-api-notification.service.js"; @@ -23,6 +27,8 @@ export class SimCancellationService { private readonly freebitService: FreebitOrchestratorService, private readonly whmcsClientService: WhmcsClientService, private readonly mappingsService: MappingsService, + private readonly opportunityService: SalesforceOpportunityService, + private readonly caseService: SalesforceCaseService, private readonly simValidation: SimValidationService, private readonly simSchedule: SimScheduleService, private readonly simActionRunner: SimActionRunnerService, @@ -183,20 +189,31 @@ export class SimCancellationService { } /** - * Cancel SIM service with full flow (PA02-04 and email notifications) + * Cancel SIM service with full flow (PA02-04, Salesforce Case + Opportunity, and email notifications) + * + * Flow: + * 1. Validate SIM subscription + * 2. Call Freebit PA02-04 API to schedule cancellation + * 3. Create Salesforce Case with all form details + * 4. Update Salesforce Opportunity (if linked) + * 5. Send email notifications */ async cancelSimFull( userId: string, subscriptionId: number, request: SimCancelFullRequest ): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId || !mapping?.sfAccountId) { + throw new BadRequestException("Account mapping not found"); + } + 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 whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId); + const clientDetails = await this.whmcsClientService.getClientDetails(mapping.whmcsClientId); const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; const customerEmail = clientDetails.email || ""; @@ -218,6 +235,14 @@ export class SimCancellationService { const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0"); const runDate = `${runYear}${runMonth}01`; + // Calculate the cancellation date (last day of selected month) + const lastDayOfMonth = new Date(year, month, 0); + const cancellationDate = [ + lastDayOfMonth.getFullYear(), + String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"), + String(lastDayOfMonth.getDate()).padStart(2, "0"), + ].join("-"); + this.logger.log(`Processing SIM cancellation via PA02-04`, { userId, subscriptionId, @@ -236,12 +261,90 @@ export class SimCancellationService { runDate, }); + // Find existing Opportunity for this subscription (by WHMCS Service ID) + let opportunityId: string | null = null; + try { + opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId); + } catch { + this.logger.warn("Could not find Opportunity for SIM subscription", { subscriptionId }); + } + + // Build description with all form data (same pattern as Internet) + const descriptionLines = [ + `Cancellation Request from Portal`, + ``, + `Product Type: SIM`, + `SIM Number: ${account}`, + `Serial Number: ${simDetails.iccid || "N/A"}`, + `WHMCS Service ID: ${subscriptionId}`, + `Cancellation Month: ${request.cancellationMonth}`, + `Service End Date: ${cancellationDate}`, + ``, + ]; + + if (request.comments) { + descriptionLines.push(`Customer Comments:`, request.comments, ``); + } + + descriptionLines.push(`Submitted: ${new Date().toISOString()}`); + + // Create Salesforce Case for cancellation (same as Internet) + let caseId: string | undefined; + try { + const caseResult = await this.caseService.createCase({ + accountId: mapping.sfAccountId, + opportunityId: opportunityId || undefined, + subject: `Cancellation Request - SIM (${request.cancellationMonth})`, + description: descriptionLines.join("\n"), + origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, + priority: "High", + }); + caseId = caseResult.id; + + this.logger.log("SIM cancellation case created", { + caseId, + opportunityId, + }); + } catch (error) { + // Log but don't fail - Freebit API was already called successfully + this.logger.error("Failed to create SIM cancellation Case", { + error: error instanceof Error ? error.message : String(error), + subscriptionId, + }); + } + + // Update Salesforce Opportunity (if linked via WHMCS_Service_ID__c) + if (opportunityId) { + try { + const cancellationData = { + scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`, + cancellationNotice: SIM_CANCELLATION_NOTICE.RECEIVED, + }; + + await this.opportunityService.updateSimCancellationData(opportunityId, cancellationData); + + this.logger.log("Opportunity updated with SIM cancellation data", { + opportunityId, + scheduledDate: cancellationDate, + }); + } catch (error) { + // Log but don't fail - Freebit API was already called successfully + this.logger.warn("Failed to update Opportunity with SIM cancellation data", { + error: error instanceof Error ? error.message : String(error), + subscriptionId, + opportunityId, + }); + } + } else { + this.logger.debug("No Opportunity linked to SIM subscription", { subscriptionId }); + } + try { await this.notifications.createNotification({ userId, type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, source: NOTIFICATION_SOURCE.SYSTEM, - sourceId: `sim:${subscriptionId}:${runDate}`, + sourceId: caseId || opportunityId || `sim:${subscriptionId}:${runDate}`, actionUrl: `/account/services/${subscriptionId}`, }); } catch (error) { @@ -261,7 +364,6 @@ export class SimCancellationService { serialNumber: simDetails.iccid, cancellationMonth: request.cancellationMonth, registeredEmail: customerEmail, - otherEmail: request.alternativeEmail || undefined, comments: request.comments, }); @@ -285,7 +387,7 @@ export class SimCancellationService { adminEmailBody ); - // Send confirmation email to customer (and alternative if provided) + // Send confirmation email to customer const confirmationSubject = "SonixNet SIM Cancellation Confirmation"; const confirmationBody = `Dear ${customerName}, @@ -305,14 +407,5 @@ Email: info@asolutions.co.jp`; 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/portal/src/features/subscriptions/components/index.ts b/apps/portal/src/features/subscriptions/components/index.ts index 59475581..6328ac5a 100644 --- a/apps/portal/src/features/subscriptions/components/index.ts +++ b/apps/portal/src/features/subscriptions/components/index.ts @@ -1 +1,2 @@ export * from "./SubscriptionStatusBadge"; +export * from "./CancellationFlow"; diff --git a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx index 7b637c2e..ab5fc8b1 100644 --- a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx @@ -1,53 +1,30 @@ "use client"; -import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState } from "react"; import { internetActionsService } from "@/features/subscriptions/api/internet-actions.api"; import type { InternetCancellationPreview } from "@customer-portal/domain/subscriptions"; -import { PageLayout } from "@/components/templates/PageLayout"; -import { SubCard } from "@/components/molecules/SubCard/SubCard"; -import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { Button } from "@/components/atoms"; import { GlobeAltIcon } from "@heroicons/react/24/outline"; - -type Step = 1 | 2 | 3; - -function Notice({ title, children }: { title: string; children: ReactNode }) { - return ( -
-
{title}
-
{children}
-
- ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} +import { + CancellationFlow, + Notice, + ServiceInfoGrid, + ServiceInfoItem, + CancellationSummary, +} from "@/features/subscriptions/components/CancellationFlow"; export function InternetCancelContainer() { const params = useParams(); const router = useRouter(); const subscriptionId = params.id as string; - const [step, setStep] = useState(1); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); 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 [selectedMonth, setSelectedMonth] = useState(""); - const [alternativeEmail, setAlternativeEmail] = useState(""); - const [alternativeEmail2, setAlternativeEmail2] = useState(""); - const [comments, setComments] = useState(""); - const [loadingPreview, setLoadingPreview] = useState(true); + const [formError, setFormError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [selectedMonthLabel, setSelectedMonthLabel] = useState(""); useEffect(() => { const fetchPreview = async () => { @@ -63,50 +40,38 @@ export function InternetCancelContainer() { : "Unable to load cancellation information right now. Please try again." ); } finally { - setLoadingPreview(false); + setLoading(false); } }; void fetchPreview(); }, [subscriptionId]); - const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0; - const emailValid = - !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 formatCurrency = (amount: number) => `¥${amount.toLocaleString()}`; - const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth); + const handleSubmit = async (data: { + cancellationMonth: string; + confirmRead: boolean; + confirmCancel: boolean; + comments?: string; + }) => { + setSubmitting(true); + setFormError(null); - const formatCurrency = (amount: number) => { - return `¥${amount.toLocaleString()}`; - }; - - const submit = async () => { - setLoading(true); - setError(null); - setMessage(null); - - if (!selectedMonth) { - setError("Please select a cancellation month before submitting."); - setLoading(false); - return; - } + // Track selected month label for success message + const monthInfo = preview?.availableMonths.find(m => m.value === data.cancellationMonth); + setSelectedMonthLabel(monthInfo?.label || data.cancellationMonth); try { await internetActionsService.submitCancellation(subscriptionId, { - cancellationMonth: selectedMonth, - confirmRead: acceptTerms, - confirmCancel: confirmMonthEnd, - alternativeEmail: alternativeEmail.trim() || undefined, - comments: comments.trim() || undefined, + cancellationMonth: data.cancellationMonth, + confirmRead: data.confirmRead, + confirmCancel: data.confirmCancel, + comments: data.comments, }); - setMessage("Cancellation request submitted. You will receive a confirmation email."); + setSuccessMessage("Cancellation request submitted. You will receive a confirmation email."); setTimeout(() => router.push(`/account/subscriptions/${subscriptionId}`), 2000); } catch (e: unknown) { - setError( + setFormError( process.env.NODE_ENV === "development" ? e instanceof Error ? e.message @@ -114,18 +79,19 @@ export function InternetCancelContainer() { : "Unable to submit your cancellation right now. Please try again." ); } finally { - setLoading(false); + setSubmitting(false); } }; - const isBlockingError = !loadingPreview && !preview && Boolean(error); - const pageError = isBlockingError ? error : null; + if (!preview && !loading && !error) { + return null; + } return ( - } - title="Cancel Internet" - description="Cancel your Internet subscription" + title="Cancel Internet Service" + description={preview?.productName || "Cancel your Internet subscription"} breadcrumbs={[ { label: "Subscriptions", href: "/account/subscriptions" }, { @@ -134,272 +100,52 @@ export function InternetCancelContainer() { }, { label: "Cancel" }, ]} - loading={loadingPreview} - error={pageError} - > - {preview ? ( -
-
- - ← Back to Subscription Details - -
- {[1, 2, 3].map(s => ( -
- ))} -
-
Step {step} of 3
-
+ backHref={`/account/subscriptions/${subscriptionId}`} + backLabel="Back to Subscription" + availableMonths={preview?.availableMonths || []} + customerEmail={preview?.customerEmail || ""} + loading={loading} + error={!preview && error ? error : null} + formError={preview ? formError : null} + successMessage={successMessage} + submitting={submitting} + confirmMessage="Are you sure you want to cancel your Internet service? This will take effect at the end of {month}." + onSubmit={handleSubmit} + serviceInfo={ + + + + + + } + termsContent={ +
+ + Online cancellations must be submitted by the 25th of the desired cancellation month. + You will receive a confirmation email once your request is accepted. + - {error && !isBlockingError ? ( - - {error} - - ) : null} - {message ? ( - - {message} - - ) : null} + + Internet equipment (ONU, router) must be returned upon cancellation. Our team will + provide return instructions after processing your request. + - -

Cancel Internet Service

-

- Cancel your Internet subscription. Please read all the information carefully before - proceeding. -

- - {step === 1 && ( -
- {/* Service Info */} -
- - - -
- - {/* Month Selection */} -
- - -

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

-
- -
- -
-
- )} - - {step === 2 && ( -
-
- - Online cancellations must be submitted by the 25th of the desired cancellation - month. Once your cancellation request is accepted, a confirmation email will be - sent to your registered email address. Our team will contact you regarding - equipment return (ONU/router) if applicable. - - - - Internet equipment (ONU, router) is typically rental hardware and must be - returned to Assist Solutions or NTT upon cancellation. Our team will provide - instructions for equipment return after processing your request. - - - - You will be billed for service through the end of your cancellation month. Any - outstanding balance or prorated charges will be processed according to your - billing cycle. - -
- -
-
- setAcceptTerms(e.target.checked)} - className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring" - /> - -
- -
- setConfirmMonthEnd(e.target.checked)} - disabled={!selectedMonth} - className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring" - /> - -
-
- -
- - -
-
- )} - - {step === 3 && ( -
- {/* Confirmation Summary */} -
-
- Cancellation Summary -
-
-
- Service: {preview?.productName} -
-
- Cancellation effective: End of{" "} - {selectedMonthInfo?.label || selectedMonth} -
-
-
- - {/* 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 */} -
-
- - setAlternativeEmail(e.target.value)} - placeholder="you@example.com" - /> -
-
- - setAlternativeEmail2(e.target.value)} - placeholder="you@example.com" - /> -
-
- - {emailProvided && !emailValid && ( -
- Please enter a valid email address in both fields. -
- )} - {emailProvided && emailValid && !emailsMatch && ( -
Email addresses do not match.
- )} - - {/* Comments */} -
- -