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.
This commit is contained in:
parent
2b001809c3
commit
6096c15659
@ -59,16 +59,26 @@ export const OPPORTUNITY_EXISTING_CUSTOM_FIELDS = {
|
|||||||
/** Application process stage (INTRO-1, N/A, etc.) */
|
/** Application process stage (INTRO-1, N/A, etc.) */
|
||||||
applicationStage: "Application_Stage__c",
|
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",
|
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",
|
cancellationNotice: "CancellationNotice__c",
|
||||||
|
|
||||||
/** Line return status for rental equipment */
|
/** Line return status for rental equipment (ONU/router) - for Internet */
|
||||||
lineReturnStatus: "LineReturn__c",
|
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;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -85,27 +95,25 @@ export const OPPORTUNITY_COMMODITY_FIELD = {
|
|||||||
} as const;
|
} 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:
|
* NOTE:
|
||||||
* - CommodityType already exists - no need to create Product_Type__c
|
* - CommodityType already exists - no need to create Product_Type__c
|
||||||
* - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp)
|
* - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp)
|
||||||
* - Orders link TO Opportunity via Order.OpportunityId (standard field)
|
* - Orders link TO Opportunity via Order.OpportunityId (standard field)
|
||||||
* - Alternative email and cancellation comments go on Case, not Opportunity
|
* - 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 = {
|
export const OPPORTUNITY_PORTAL_INTEGRATION_FIELDS = {
|
||||||
// ---- Source Field (to be created) ----
|
// ---- Source Field (existing) ----
|
||||||
|
|
||||||
/** Source of the Opportunity creation */
|
/** 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) */
|
/** WHMCS Service ID (populated after provisioning) */
|
||||||
whmcsServiceId: "WHMCS_Service_ID__c",
|
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||||
@ -125,7 +133,7 @@ export const OPPORTUNITY_FIELD_MAP = {
|
|||||||
...OPPORTUNITY_STANDARD_FIELDS,
|
...OPPORTUNITY_STANDARD_FIELDS,
|
||||||
...OPPORTUNITY_EXISTING_CUSTOM_FIELDS,
|
...OPPORTUNITY_EXISTING_CUSTOM_FIELDS,
|
||||||
...OPPORTUNITY_COMMODITY_FIELD,
|
...OPPORTUNITY_COMMODITY_FIELD,
|
||||||
...OPPORTUNITY_NEW_CUSTOM_FIELDS,
|
...OPPORTUNITY_PORTAL_INTEGRATION_FIELDS,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP;
|
export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP;
|
||||||
@ -156,17 +164,20 @@ export const OPPORTUNITY_MATCH_QUERY_FIELDS = [
|
|||||||
export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [
|
export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [
|
||||||
...OPPORTUNITY_MATCH_QUERY_FIELDS,
|
...OPPORTUNITY_MATCH_QUERY_FIELDS,
|
||||||
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||||
|
// Internet cancellation fields
|
||||||
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
|
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
|
||||||
OPPORTUNITY_FIELD_MAP.cancellationNotice,
|
OPPORTUNITY_FIELD_MAP.cancellationNotice,
|
||||||
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
|
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
|
||||||
|
// SIM cancellation fields
|
||||||
|
OPPORTUNITY_FIELD_MAP.simCancellationNotice,
|
||||||
|
OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate,
|
||||||
OPPORTUNITY_FIELD_MAP.lastModifiedDate,
|
OPPORTUNITY_FIELD_MAP.lastModifiedDate,
|
||||||
// NOTE: Cancellation comments and alternative email are on the Cancellation Case
|
|
||||||
] as const;
|
] 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.id,
|
||||||
OPPORTUNITY_FIELD_MAP.stage,
|
OPPORTUNITY_FIELD_MAP.stage,
|
||||||
OPPORTUNITY_FIELD_MAP.commodityType,
|
OPPORTUNITY_FIELD_MAP.commodityType,
|
||||||
@ -176,6 +187,23 @@ export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [
|
|||||||
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||||
] as const;
|
] 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)
|
// Stage Picklist Reference (Existing Values)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -353,14 +381,13 @@ export const COMMODITY_TYPE_REFERENCE = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// New Picklist Values (to be created in Salesforce)
|
// Opportunity Source Picklist Reference (Existing Values)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source picklist values for Portal_Source__c field
|
* Source picklist values for Opportunity_Source__c field (existing in Salesforce)
|
||||||
* (needs to be created in Salesforce)
|
|
||||||
*/
|
*/
|
||||||
export const PORTAL_SOURCE_PICKLIST = [
|
export const OPPORTUNITY_SOURCE_PICKLIST = [
|
||||||
{ value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" },
|
{ value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" },
|
||||||
{ value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" },
|
{ value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" },
|
||||||
{ value: "Portal - Order Placement", label: "Portal - Order Placement" },
|
{ value: "Portal - Order Placement", label: "Portal - Order Placement" },
|
||||||
|
|||||||
@ -80,13 +80,17 @@ export const OPPORTUNITY_FIELDS = {
|
|||||||
applicationStage: "Application_Stage__c",
|
applicationStage: "Application_Stage__c",
|
||||||
|
|
||||||
// Portal integration
|
// Portal integration
|
||||||
portalSource: "Portal_Source__c",
|
opportunitySource: "Opportunity_Source__c",
|
||||||
whmcsServiceId: "WHMCS_Service_ID__c",
|
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||||
|
|
||||||
// Cancellation
|
// Internet Cancellation
|
||||||
cancellationNotice: "CancellationNotice__c",
|
cancellationNotice: "CancellationNotice__c",
|
||||||
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
|
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
|
||||||
lineReturn: "LineReturn__c",
|
lineReturn: "LineReturn__c",
|
||||||
|
|
||||||
|
// SIM Cancellation
|
||||||
|
simCancellationNotice: "SIMCancellationNotice__c",
|
||||||
|
simScheduledCancellationDate: "SIMScheduledCancellationDateAndTime__c",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS;
|
export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS;
|
||||||
|
|||||||
@ -45,7 +45,8 @@ import {
|
|||||||
OPPORTUNITY_FIELD_MAP,
|
OPPORTUNITY_FIELD_MAP,
|
||||||
OPPORTUNITY_MATCH_QUERY_FIELDS,
|
OPPORTUNITY_MATCH_QUERY_FIELDS,
|
||||||
OPPORTUNITY_DETAIL_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";
|
} from "../config/opportunity-field-map.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -68,18 +69,41 @@ interface SalesforceOpportunityRecord {
|
|||||||
// Existing custom fields
|
// Existing custom fields
|
||||||
Application_Stage__c?: string;
|
Application_Stage__c?: string;
|
||||||
CommodityType?: string; // Existing product type field
|
CommodityType?: string; // Existing product type field
|
||||||
|
// Internet cancellation fields
|
||||||
ScheduledCancellationDateAndTime__c?: string;
|
ScheduledCancellationDateAndTime__c?: string;
|
||||||
CancellationNotice__c?: string;
|
CancellationNotice__c?: string;
|
||||||
LineReturn__c?: string;
|
LineReturn__c?: string;
|
||||||
// New custom fields (to be created)
|
// SIM cancellation fields
|
||||||
Portal_Source__c?: string;
|
SIMCancellationNotice__c?: string;
|
||||||
|
SIMScheduledCancellationDateAndTime__c?: string;
|
||||||
|
// Portal integration custom fields (existing)
|
||||||
|
Opportunity_Source__c?: string;
|
||||||
WHMCS_Service_ID__c?: number;
|
WHMCS_Service_ID__c?: number;
|
||||||
// Note: Cases and Orders link TO Opportunity via their OpportunityId field
|
// Note: Cases and Orders link TO Opportunity via their OpportunityId field
|
||||||
// Cancellation comments and alternative email are on the Cancellation Case
|
|
||||||
// Relationship fields
|
// Relationship fields
|
||||||
Account?: { Name?: string };
|
Account?: { Name?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InternetCancellationOpportunityDataInput = Pick<
|
||||||
|
CancellationOpportunityData,
|
||||||
|
"scheduledCancellationDate" | "cancellationNotice" | "lineReturnStatus"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SimCancellationOpportunityDataInput = Pick<
|
||||||
|
CancellationOpportunityData,
|
||||||
|
"scheduledCancellationDate" | "cancellationNotice"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStringField(record: Record<string, unknown>, field: string): string {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
throw new Error(`Invalid ${field}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Service
|
// 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:
|
* Sets:
|
||||||
* - Stage to △Cancelling
|
* - Stage to △Cancelling
|
||||||
@ -246,26 +270,37 @@ export class SalesforceOpportunityService {
|
|||||||
* The Case is created separately and linked to this Opportunity via Case.OpportunityId.
|
* The Case is created separately and linked to this Opportunity via Case.OpportunityId.
|
||||||
*
|
*
|
||||||
* @param opportunityId - Salesforce Opportunity ID
|
* @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,
|
opportunityId: string,
|
||||||
data: CancellationOpportunityData
|
data: InternetCancellationOpportunityDataInput
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
|
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,
|
opportunityId: safeOppId,
|
||||||
scheduledDate: data.scheduledCancellationDate,
|
scheduledDate: safeData.scheduledCancellationDate,
|
||||||
cancellationNotice: data.cancellationNotice,
|
cancellationNotice: safeData.cancellationNotice,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
Id: safeOppId,
|
Id: safeOppId,
|
||||||
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
|
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
|
||||||
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate,
|
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate,
|
||||||
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice,
|
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: safeData.cancellationNotice,
|
||||||
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.lineReturnStatus,
|
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: safeData.lineReturnStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -276,12 +311,12 @@ export class SalesforceOpportunityService {
|
|||||||
|
|
||||||
await updateMethod(payload as Record<string, unknown> & { Id: string });
|
await updateMethod(payload as Record<string, unknown> & { Id: string });
|
||||||
|
|
||||||
this.logger.log("Opportunity cancellation data updated successfully", {
|
this.logger.log("Opportunity Internet cancellation data updated successfully", {
|
||||||
opportunityId: safeOppId,
|
opportunityId: safeOppId,
|
||||||
scheduledDate: data.scheduledCancellationDate,
|
scheduledDate: safeData.scheduledCancellationDate,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to update Opportunity cancellation data", {
|
this.logger.error("Failed to update Opportunity Internet cancellation data", {
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
opportunityId: safeOppId,
|
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<void> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> & { 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<void> {
|
||||||
|
return this.updateInternetCancellationData(opportunityId, data);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Lookup Operations
|
// Lookup Operations
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -506,7 +614,13 @@ export class SalesforceOpportunityService {
|
|||||||
* @param whmcsServiceId - WHMCS Service ID
|
* @param whmcsServiceId - WHMCS Service ID
|
||||||
* @returns Cancellation status details or null
|
* @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;
|
stage: OpportunityStageValue;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
@ -514,7 +628,7 @@ export class SalesforceOpportunityService {
|
|||||||
rentalReturnStatus?: LineReturnStatusValue;
|
rentalReturnStatus?: LineReturnStatusValue;
|
||||||
} | null> {
|
} | null> {
|
||||||
const soql = `
|
const soql = `
|
||||||
SELECT ${OPPORTUNITY_CANCELLATION_QUERY_FIELDS.join(", ")}
|
SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")}
|
||||||
FROM Opportunity
|
FROM Opportunity
|
||||||
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
|
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
|
||||||
ORDER BY CreatedDate DESC
|
ORDER BY CreatedDate DESC
|
||||||
@ -523,7 +637,7 @@ export class SalesforceOpportunityService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = (await this.sf.query(soql, {
|
const result = (await this.sf.query(soql, {
|
||||||
label: "opportunity:getCancellationStatus",
|
label: "opportunity:getInternetCancellationStatus",
|
||||||
})) as SalesforceResponse<SalesforceOpportunityRecord>;
|
})) as SalesforceResponse<SalesforceOpportunityRecord>;
|
||||||
|
|
||||||
const record = result.records?.[0];
|
const record = result.records?.[0];
|
||||||
@ -541,7 +655,7 @@ export class SalesforceOpportunityService {
|
|||||||
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
|
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to get cancellation status", {
|
this.logger.error("Failed to get Internet cancellation status", {
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
whmcsServiceId,
|
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<SalesforceOpportunityRecord>;
|
||||||
|
|
||||||
|
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
|
// Lifecycle Helpers
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -724,7 +898,7 @@ export class SalesforceOpportunityService {
|
|||||||
closeDate: record.CloseDate,
|
closeDate: record.CloseDate,
|
||||||
commodityType,
|
commodityType,
|
||||||
productType: productType ?? undefined,
|
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,
|
applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined,
|
||||||
isClosed: record.IsClosed,
|
isClosed: record.IsClosed,
|
||||||
// Note: Related Cases and Orders are queried separately via their OpportunityId field
|
// Note: Related Cases and Orders are queried separately via their OpportunityId field
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import type {
|
|||||||
InternetCancelRequest,
|
InternetCancelRequest,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import {
|
import {
|
||||||
type CancellationOpportunityData,
|
type InternetCancellationOpportunityData,
|
||||||
CANCELLATION_NOTICE,
|
CANCELLATION_NOTICE,
|
||||||
LINE_RETURN_STATUS,
|
LINE_RETURN_STATUS,
|
||||||
} from "@customer-portal/domain/opportunity";
|
} 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) {
|
if (request.comments) {
|
||||||
descriptionLines.push(``, `Customer Comments:`, request.comments);
|
descriptionLines.push(``, `Customer Comments:`, request.comments);
|
||||||
}
|
}
|
||||||
@ -282,13 +278,16 @@ export class InternetCancellationService {
|
|||||||
// Update Opportunity if found
|
// Update Opportunity if found
|
||||||
if (opportunityId) {
|
if (opportunityId) {
|
||||||
try {
|
try {
|
||||||
const cancellationData: CancellationOpportunityData = {
|
const cancellationData: InternetCancellationOpportunityData = {
|
||||||
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
|
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
|
||||||
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
|
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
|
||||||
lineReturnStatus: LINE_RETURN_STATUS.NOT_YET,
|
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", {
|
this.logger.log("Opportunity updated with cancellation data", {
|
||||||
opportunityId,
|
opportunityId,
|
||||||
@ -327,15 +326,6 @@ Email: info@asolutions.co.jp`;
|
|||||||
subject: confirmationSubject,
|
subject: confirmationSubject,
|
||||||
text: confirmationBody,
|
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) {
|
} catch (error) {
|
||||||
// Log but don't fail - Case was already created
|
// Log but don't fail - Case was already created
|
||||||
this.logger.error("Failed to send cancellation confirmation email", {
|
this.logger.error("Failed to send cancellation confirmation email", {
|
||||||
|
|||||||
@ -154,7 +154,6 @@ Email: ${ADMIN_EMAIL}`;
|
|||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
cancellationMonth: string;
|
cancellationMonth: string;
|
||||||
registeredEmail: string;
|
registeredEmail: string;
|
||||||
otherEmail?: string;
|
|
||||||
comments?: string;
|
comments?: string;
|
||||||
}): string {
|
}): string {
|
||||||
return `The following SONIXNET SIM cancellation has been requested.
|
return `The following SONIXNET SIM cancellation has been requested.
|
||||||
@ -164,7 +163,6 @@ SIM #: ${params.simNumber}
|
|||||||
Serial #: ${params.serialNumber || "N/A"}
|
Serial #: ${params.serialNumber || "N/A"}
|
||||||
Cancellation month: ${params.cancellationMonth}
|
Cancellation month: ${params.cancellationMonth}
|
||||||
Registered email address: ${params.registeredEmail}
|
Registered email address: ${params.registeredEmail}
|
||||||
Other email address: ${params.otherEmail || "N/A"}
|
|
||||||
Comments: ${params.comments || "N/A"}`;
|
Comments: ${params.comments || "N/A"}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.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 { SimValidationService } from "./sim-validation.service.js";
|
||||||
import type {
|
import type {
|
||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
@ -11,6 +13,8 @@ import type {
|
|||||||
SimCancellationMonth,
|
SimCancellationMonth,
|
||||||
SimCancellationPreview,
|
SimCancellationPreview,
|
||||||
} from "@customer-portal/domain/sim";
|
} 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 { SimScheduleService } from "./sim-schedule.service.js";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
||||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
||||||
@ -23,6 +27,8 @@ export class SimCancellationService {
|
|||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
|
private readonly caseService: SalesforceCaseService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simSchedule: SimScheduleService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
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(
|
async cancelSimFull(
|
||||||
userId: string,
|
userId: string,
|
||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimCancelFullRequest
|
request: SimCancelFullRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
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 validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
const account = validation.account;
|
const account = validation.account;
|
||||||
const simDetails = await this.freebitService.getSimDetails(account);
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
// Get customer info from WHMCS
|
// Get customer info from WHMCS
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
const clientDetails = await this.whmcsClientService.getClientDetails(mapping.whmcsClientId);
|
||||||
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
|
||||||
const customerName =
|
const customerName =
|
||||||
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
const customerEmail = clientDetails.email || "";
|
const customerEmail = clientDetails.email || "";
|
||||||
@ -218,6 +235,14 @@ export class SimCancellationService {
|
|||||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
const runDate = `${runYear}${runMonth}01`;
|
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`, {
|
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -236,12 +261,90 @@ export class SimCancellationService {
|
|||||||
runDate,
|
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 {
|
try {
|
||||||
await this.notifications.createNotification({
|
await this.notifications.createNotification({
|
||||||
userId,
|
userId,
|
||||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||||
sourceId: `sim:${subscriptionId}:${runDate}`,
|
sourceId: caseId || opportunityId || `sim:${subscriptionId}:${runDate}`,
|
||||||
actionUrl: `/account/services/${subscriptionId}`,
|
actionUrl: `/account/services/${subscriptionId}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -261,7 +364,6 @@ export class SimCancellationService {
|
|||||||
serialNumber: simDetails.iccid,
|
serialNumber: simDetails.iccid,
|
||||||
cancellationMonth: request.cancellationMonth,
|
cancellationMonth: request.cancellationMonth,
|
||||||
registeredEmail: customerEmail,
|
registeredEmail: customerEmail,
|
||||||
otherEmail: request.alternativeEmail || undefined,
|
|
||||||
comments: request.comments,
|
comments: request.comments,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,7 +387,7 @@ export class SimCancellationService {
|
|||||||
adminEmailBody
|
adminEmailBody
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send confirmation email to customer (and alternative if provided)
|
// Send confirmation email to customer
|
||||||
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
|
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
|
||||||
const confirmationBody = `Dear ${customerName},
|
const confirmationBody = `Dear ${customerName},
|
||||||
|
|
||||||
@ -305,14 +407,5 @@ Email: info@asolutions.co.jp`;
|
|||||||
confirmationSubject,
|
confirmationSubject,
|
||||||
confirmationBody
|
confirmationBody
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send to alternative email if provided
|
|
||||||
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
|
|
||||||
await this.apiNotification.sendCustomerEmail(
|
|
||||||
request.alternativeEmail,
|
|
||||||
confirmationSubject,
|
|
||||||
confirmationBody
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./SubscriptionStatusBadge";
|
export * from "./SubscriptionStatusBadge";
|
||||||
|
export * from "./CancellationFlow";
|
||||||
|
|||||||
@ -1,53 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { internetActionsService } from "@/features/subscriptions/api/internet-actions.api";
|
||||||
import type { InternetCancellationPreview } from "@customer-portal/domain/subscriptions";
|
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";
|
import { GlobeAltIcon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
type Step = 1 | 2 | 3;
|
CancellationFlow,
|
||||||
|
Notice,
|
||||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
ServiceInfoGrid,
|
||||||
return (
|
ServiceInfoItem,
|
||||||
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
CancellationSummary,
|
||||||
<div className="text-sm font-semibold text-foreground mb-2">{title}</div>
|
} from "@/features/subscriptions/components/CancellationFlow";
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
<div className="text-sm font-medium text-foreground">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InternetCancelContainer() {
|
export function InternetCancelContainer() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const subscriptionId = params.id as string;
|
const subscriptionId = params.id as string;
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>(1);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [preview, setPreview] = useState<InternetCancellationPreview | null>(null);
|
const [preview, setPreview] = useState<InternetCancellationPreview | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
|
||||||
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
|
||||||
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
|
||||||
const [comments, setComments] = useState<string>("");
|
|
||||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPreview = async () => {
|
const fetchPreview = async () => {
|
||||||
@ -63,50 +40,38 @@ export function InternetCancelContainer() {
|
|||||||
: "Unable to load cancellation information right now. Please try again."
|
: "Unable to load cancellation information right now. Please try again."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPreview(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void fetchPreview();
|
void fetchPreview();
|
||||||
}, [subscriptionId]);
|
}, [subscriptionId]);
|
||||||
|
|
||||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const formatCurrency = (amount: number) => `¥${amount.toLocaleString()}`;
|
||||||
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 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) => {
|
// Track selected month label for success message
|
||||||
return `¥${amount.toLocaleString()}`;
|
const monthInfo = preview?.availableMonths.find(m => m.value === data.cancellationMonth);
|
||||||
};
|
setSelectedMonthLabel(monthInfo?.label || data.cancellationMonth);
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
if (!selectedMonth) {
|
|
||||||
setError("Please select a cancellation month before submitting.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await internetActionsService.submitCancellation(subscriptionId, {
|
await internetActionsService.submitCancellation(subscriptionId, {
|
||||||
cancellationMonth: selectedMonth,
|
cancellationMonth: data.cancellationMonth,
|
||||||
confirmRead: acceptTerms,
|
confirmRead: data.confirmRead,
|
||||||
confirmCancel: confirmMonthEnd,
|
confirmCancel: data.confirmCancel,
|
||||||
alternativeEmail: alternativeEmail.trim() || undefined,
|
comments: data.comments,
|
||||||
comments: comments.trim() || undefined,
|
|
||||||
});
|
});
|
||||||
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);
|
setTimeout(() => router.push(`/account/subscriptions/${subscriptionId}`), 2000);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(
|
setFormError(
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? e instanceof Error
|
? e instanceof Error
|
||||||
? e.message
|
? e.message
|
||||||
@ -114,18 +79,19 @@ export function InternetCancelContainer() {
|
|||||||
: "Unable to submit your cancellation right now. Please try again."
|
: "Unable to submit your cancellation right now. Please try again."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBlockingError = !loadingPreview && !preview && Boolean(error);
|
if (!preview && !loading && !error) {
|
||||||
const pageError = isBlockingError ? error : null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<CancellationFlow
|
||||||
icon={<GlobeAltIcon />}
|
icon={<GlobeAltIcon />}
|
||||||
title="Cancel Internet"
|
title="Cancel Internet Service"
|
||||||
description="Cancel your Internet subscription"
|
description={preview?.productName || "Cancel your Internet subscription"}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||||
{
|
{
|
||||||
@ -134,272 +100,52 @@ export function InternetCancelContainer() {
|
|||||||
},
|
},
|
||||||
{ label: "Cancel" },
|
{ label: "Cancel" },
|
||||||
]}
|
]}
|
||||||
loading={loadingPreview}
|
backHref={`/account/subscriptions/${subscriptionId}`}
|
||||||
error={pageError}
|
backLabel="Back to Subscription"
|
||||||
>
|
availableMonths={preview?.availableMonths || []}
|
||||||
{preview ? (
|
customerEmail={preview?.customerEmail || ""}
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
loading={loading}
|
||||||
<div className="mb-2">
|
error={!preview && error ? error : null}
|
||||||
<Link
|
formError={preview ? formError : null}
|
||||||
href={`/account/subscriptions/${subscriptionId}`}
|
successMessage={successMessage}
|
||||||
className="text-primary hover:underline"
|
submitting={submitting}
|
||||||
>
|
confirmMessage="Are you sure you want to cancel your Internet service? This will take effect at the end of {month}."
|
||||||
← Back to Subscription Details
|
onSubmit={handleSubmit}
|
||||||
</Link>
|
serviceInfo={
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<ServiceInfoGrid>
|
||||||
{[1, 2, 3].map(s => (
|
<ServiceInfoItem label="Service" value={preview?.productName || "—"} />
|
||||||
<div
|
<ServiceInfoItem
|
||||||
key={s}
|
label="Monthly"
|
||||||
className={`h-2 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-border"}`}
|
value={preview?.billingAmount ? formatCurrency(preview.billingAmount) : "—"}
|
||||||
/>
|
/>
|
||||||
))}
|
<ServiceInfoItem label="Next Due" value={preview?.nextDueDate || "—"} />
|
||||||
</div>
|
</ServiceInfoGrid>
|
||||||
<div className="text-sm text-muted-foreground mt-1">Step {step} of 3</div>
|
}
|
||||||
</div>
|
termsContent={
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Notice title="Cancellation Deadline">
|
||||||
|
Online cancellations must be submitted by the 25th of the desired cancellation month.
|
||||||
|
You will receive a confirmation email once your request is accepted.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
{error && !isBlockingError ? (
|
<Notice title="Equipment Return">
|
||||||
<AlertBanner variant="error" title="Unable to proceed" elevated>
|
Internet equipment (ONU, router) must be returned upon cancellation. Our team will
|
||||||
{error}
|
provide return instructions after processing your request.
|
||||||
</AlertBanner>
|
</Notice>
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<AlertBanner variant="success" title="Request submitted" elevated>
|
|
||||||
{message}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SubCard>
|
<Notice title="Final Billing">
|
||||||
<h1 className="text-xl font-semibold text-foreground mb-2">Cancel Internet Service</h1>
|
You will be billed through the end of your cancellation month. Any outstanding balance
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
will be processed according to your billing cycle.
|
||||||
Cancel your Internet subscription. Please read all the information carefully before
|
</Notice>
|
||||||
proceeding.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Service Info */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-muted border border-border rounded-lg">
|
|
||||||
<InfoRow label="Service" value={preview?.productName || "—"} />
|
|
||||||
<InfoRow
|
|
||||||
label="Monthly Amount"
|
|
||||||
value={preview?.billingAmount ? formatCurrency(preview.billingAmount) : "—"}
|
|
||||||
/>
|
|
||||||
<InfoRow label="Next Due" value={preview?.nextDueDate || "—"} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Month Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Select Cancellation Month
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedMonth}
|
|
||||||
onChange={e => {
|
|
||||||
setSelectedMonth(e.target.value);
|
|
||||||
setConfirmMonthEnd(false);
|
|
||||||
}}
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
>
|
|
||||||
<option value="">Select month…</option>
|
|
||||||
{preview?.availableMonths.map(month => (
|
|
||||||
<option key={month.value} value={month.value}>
|
|
||||||
{month.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Your subscription will be cancelled at the end of the selected month.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button disabled={!canProceedStep2} onClick={() => setStep(2)}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Notice title="[Cancellation Procedure]">
|
|
||||||
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.
|
|
||||||
</Notice>
|
|
||||||
|
|
||||||
<Notice title="[Equipment Return]">
|
|
||||||
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.
|
|
||||||
</Notice>
|
|
||||||
|
|
||||||
<Notice title="[Final Billing]">
|
|
||||||
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.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 bg-muted border border-border rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="acceptTerms"
|
|
||||||
type="checkbox"
|
|
||||||
checked={acceptTerms}
|
|
||||||
onChange={e => setAcceptTerms(e.target.checked)}
|
|
||||||
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<label htmlFor="acceptTerms" className="text-sm text-foreground/80">
|
|
||||||
I have read and understood the cancellation terms above.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="confirmMonthEnd"
|
|
||||||
type="checkbox"
|
|
||||||
checked={confirmMonthEnd}
|
|
||||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
|
||||||
disabled={!selectedMonth}
|
|
||||||
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<label htmlFor="confirmMonthEnd" className="text-sm text-foreground/80">
|
|
||||||
I would like to cancel my Internet subscription at the end of{" "}
|
|
||||||
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setStep(1)}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button disabled={!canProceedStep3} onClick={() => setStep(3)}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Confirmation Summary */}
|
|
||||||
<div className="bg-info-soft border border-info/25 rounded-lg p-4">
|
|
||||||
<div className="text-sm font-semibold text-foreground mb-2">
|
|
||||||
Cancellation Summary
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
|
||||||
<div>
|
|
||||||
<strong>Service:</strong> {preview?.productName}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Cancellation effective:</strong> End of{" "}
|
|
||||||
{selectedMonthInfo?.label || selectedMonth}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Registered Email */}
|
|
||||||
<div className="text-sm text-foreground/80">
|
|
||||||
Your registered email address is:{" "}
|
|
||||||
<span className="font-medium">{preview?.customerEmail || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
You will receive a cancellation confirmation email. If you would like to receive
|
|
||||||
this email on a different address, please enter the address below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alternative Email */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
||||||
Email address:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
value={alternativeEmail}
|
|
||||||
onChange={e => setAlternativeEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
||||||
(Confirm):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
value={alternativeEmail2}
|
|
||||||
onChange={e => setAlternativeEmail2(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{emailProvided && !emailValid && (
|
|
||||||
<div className="text-xs text-danger">
|
|
||||||
Please enter a valid email address in both fields.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{emailProvided && emailValid && !emailsMatch && (
|
|
||||||
<div className="text-xs text-danger">Email addresses do not match.</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
||||||
If you have any questions or comments regarding your cancellation, please note
|
|
||||||
them below and our team will contact you.
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
rows={4}
|
|
||||||
value={comments}
|
|
||||||
onChange={e => setComments(e.target.value)}
|
|
||||||
placeholder="Optional: Enter any questions or requests here."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Final Warning */}
|
|
||||||
<div className="bg-danger-soft border border-danger/25 rounded-lg p-4">
|
|
||||||
<div className="text-sm font-semibold text-foreground mb-1">
|
|
||||||
Your cancellation request is not confirmed yet.
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
This is the final step. Click "Request Cancellation" to submit your request.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setStep(2)}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
`Are you sure you want to cancel your Internet service? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
void submit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading || !canProceedStep3}
|
|
||||||
loading={loading}
|
|
||||||
loadingText="Processing…"
|
|
||||||
>
|
|
||||||
REQUEST CANCELLATION
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
}
|
||||||
</PageLayout>
|
summaryContent={
|
||||||
|
<CancellationSummary
|
||||||
|
items={[{ label: "Service", value: preview?.productName || "—" }]}
|
||||||
|
selectedMonth={selectedMonthLabel || "the selected month"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,53 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
|
import { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
|
||||||
import type { SimCancellationPreview } from "@customer-portal/domain/sim";
|
import type { SimCancellationPreview } from "@customer-portal/domain/sim";
|
||||||
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 { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
type Step = 1 | 2 | 3;
|
CancellationFlow,
|
||||||
|
Notice,
|
||||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
InfoNotice,
|
||||||
return (
|
ServiceInfoGrid,
|
||||||
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
ServiceInfoItem,
|
||||||
<div className="text-sm font-semibold text-foreground mb-2">{title}</div>
|
CancellationSummary,
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed">{children}</div>
|
MinimumContractWarning,
|
||||||
</div>
|
} from "@/features/subscriptions/components/CancellationFlow";
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
<div className="text-sm font-medium text-foreground">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SimCancelContainer() {
|
export function SimCancelContainer() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const subscriptionId = params.id as string;
|
const subscriptionId = params.id as string;
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>(1);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [preview, setPreview] = useState<SimCancellationPreview | null>(null);
|
const [preview, setPreview] = useState<SimCancellationPreview | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
|
||||||
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
|
||||||
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
|
||||||
const [comments, setComments] = useState<string>("");
|
|
||||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPreview = async () => {
|
const fetchPreview = async () => {
|
||||||
@ -63,49 +42,39 @@ export function SimCancelContainer() {
|
|||||||
: "Unable to load cancellation information right now. Please try again."
|
: "Unable to load cancellation information right now. Please try again."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPreview(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void fetchPreview();
|
void fetchPreview();
|
||||||
}, [subscriptionId]);
|
}, [subscriptionId]);
|
||||||
|
|
||||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const handleSubmit = async (data: {
|
||||||
const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0;
|
cancellationMonth: string;
|
||||||
const emailValid =
|
confirmRead: boolean;
|
||||||
!emailProvided ||
|
confirmCancel: boolean;
|
||||||
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
|
comments?: string;
|
||||||
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
|
}) => {
|
||||||
const canProceedStep2 = !!preview && !!selectedMonth;
|
setSubmitting(true);
|
||||||
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
|
setFormError(null);
|
||||||
|
|
||||||
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
|
// Track selected month label for success message
|
||||||
|
const monthInfo = preview?.availableMonths.find(m => m.value === data.cancellationMonth);
|
||||||
const submit = async () => {
|
setSelectedMonthLabel(monthInfo?.label || data.cancellationMonth);
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
if (!selectedMonth) {
|
|
||||||
setError("Please select a cancellation month before submitting.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await simActionsService.cancelFull(subscriptionId, {
|
await simActionsService.cancelFull(subscriptionId, {
|
||||||
cancellationMonth: selectedMonth,
|
cancellationMonth: data.cancellationMonth,
|
||||||
confirmRead: acceptTerms,
|
confirmRead: data.confirmRead,
|
||||||
confirmCancel: confirmMonthEnd,
|
confirmCancel: data.confirmCancel,
|
||||||
alternativeEmail: alternativeEmail.trim() || undefined,
|
comments: data.comments,
|
||||||
comments: comments.trim() || undefined,
|
|
||||||
});
|
});
|
||||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
setSuccessMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => router.push(`/account/subscriptions/${subscriptionId}#sim-management`),
|
() => router.push(`/account/subscriptions/${subscriptionId}#sim-management`),
|
||||||
2000
|
2000
|
||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(
|
setFormError(
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? e instanceof Error
|
? e instanceof Error
|
||||||
? e.message
|
? e.message
|
||||||
@ -113,18 +82,21 @@ export function SimCancelContainer() {
|
|||||||
: "Unable to submit your cancellation right now. Please try again."
|
: "Unable to submit your cancellation right now. Please try again."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBlockingError = !loadingPreview && !preview && Boolean(error);
|
if (!preview && !loading && !error) {
|
||||||
const pageError = isBlockingError ? error : null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<CancellationFlow
|
||||||
icon={<DevicePhoneMobileIcon />}
|
icon={<DevicePhoneMobileIcon />}
|
||||||
title="Cancel SIM"
|
title="Cancel SIM Service"
|
||||||
description="Cancel your SIM subscription"
|
description={
|
||||||
|
preview?.simNumber ? `SIM: ${preview.simNumber}` : "Cancel your SIM subscription"
|
||||||
|
}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||||
{
|
{
|
||||||
@ -133,300 +105,66 @@ export function SimCancelContainer() {
|
|||||||
},
|
},
|
||||||
{ label: "Cancel SIM" },
|
{ label: "Cancel SIM" },
|
||||||
]}
|
]}
|
||||||
loading={loadingPreview}
|
backHref={`/account/subscriptions/${subscriptionId}#sim-management`}
|
||||||
error={pageError}
|
backLabel="Back to SIM Management"
|
||||||
>
|
availableMonths={preview?.availableMonths || []}
|
||||||
{preview ? (
|
customerEmail={preview?.customerEmail || ""}
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
loading={loading}
|
||||||
<div className="mb-2">
|
error={!preview && error ? error : null}
|
||||||
<Link
|
formError={preview ? formError : null}
|
||||||
href={`/account/subscriptions/${subscriptionId}#sim-management`}
|
successMessage={successMessage}
|
||||||
className="text-primary hover:underline"
|
submitting={submitting}
|
||||||
>
|
confirmMessage="Are you sure you want to cancel your SIM subscription? This will take effect at the end of {month}."
|
||||||
← Back to SIM Management
|
onSubmit={handleSubmit}
|
||||||
</Link>
|
warningBanner={
|
||||||
<div className="flex items-center gap-2 mt-2">
|
preview?.isWithinMinimumTerm && preview.minimumContractEndDate ? (
|
||||||
{[1, 2, 3].map(s => (
|
<MinimumContractWarning endDate={preview.minimumContractEndDate} />
|
||||||
<div
|
) : null
|
||||||
key={s}
|
}
|
||||||
className={`h-2 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-border"}`}
|
serviceInfo={
|
||||||
/>
|
<ServiceInfoGrid>
|
||||||
))}
|
<ServiceInfoItem label="SIM Number" value={preview?.simNumber || "—"} />
|
||||||
</div>
|
<ServiceInfoItem label="Serial #" value={preview?.serialNumber || "—"} mono />
|
||||||
<div className="text-sm text-muted-foreground mt-1">Step {step} of 3</div>
|
<ServiceInfoItem label="Start Date" value={preview?.startDate || "—"} />
|
||||||
</div>
|
</ServiceInfoGrid>
|
||||||
|
}
|
||||||
|
termsContent={
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Notice title="Cancellation Deadline">
|
||||||
|
Online cancellations must be submitted by the 25th of the desired month. The SIM card
|
||||||
|
must be returned to Assist Solutions upon cancellation. This cancellation applies to SIM
|
||||||
|
subscriptions only.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
{error && !isBlockingError ? (
|
<Notice title="Minimum Contract Term">
|
||||||
<AlertBanner variant="error" title="Unable to proceed" elevated>
|
The SONIXNET SIM has a 3-month minimum contract term (sign-up month not included). Early
|
||||||
{error}
|
cancellation will incur charges for remaining months.
|
||||||
</AlertBanner>
|
</Notice>
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<AlertBanner variant="success" title="Request submitted" elevated>
|
|
||||||
{message}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SubCard>
|
<Notice title="Option Services">
|
||||||
<h1 className="text-xl font-semibold text-foreground mb-2">Cancel SIM</h1>
|
Cancelling the base plan will also cancel all associated options (Voice Mail, Call
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
Waiting). To cancel options only, please contact support.
|
||||||
Cancel your SIM subscription. Please read all the information carefully before
|
</Notice>
|
||||||
proceeding.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Minimum Contract Warning */}
|
<Notice title="MNP Transfer">
|
||||||
{preview?.isWithinMinimumTerm && (
|
Your phone number will be lost upon cancellation. To keep the number via MNP transfer
|
||||||
<div className="bg-danger-soft border border-danger/25 rounded-lg p-4 mb-6">
|
(¥1,000+tax), contact Assist Solutions before cancelling.
|
||||||
<div className="text-sm font-semibold text-foreground mb-1">
|
</Notice>
|
||||||
Minimum Contract Term Warning
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Your subscription is still within the minimum contract period (ends{" "}
|
|
||||||
{preview.minimumContractEndDate}). Early cancellation may result in additional
|
|
||||||
charges for the remaining months.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* SIM Info */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-muted border border-border rounded-lg">
|
|
||||||
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
|
|
||||||
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
|
|
||||||
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Month Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Select Cancellation Month
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedMonth}
|
|
||||||
onChange={e => {
|
|
||||||
setSelectedMonth(e.target.value);
|
|
||||||
setConfirmMonthEnd(false);
|
|
||||||
}}
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
>
|
|
||||||
<option value="">Select month…</option>
|
|
||||||
{preview?.availableMonths.map(month => (
|
|
||||||
<option key={month.value} value={month.value}>
|
|
||||||
{month.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Your subscription will be cancelled at the end of the selected month.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button disabled={!canProceedStep2} onClick={() => setStep(2)}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Notice title="[Cancellation Procedure]">
|
|
||||||
Online cancellations must be made from this website by the 25th of the desired
|
|
||||||
cancellation month. Once a request of a cancellation of the SONIXNET SIM is
|
|
||||||
accepted from this online form, a confirmation email containing details of the
|
|
||||||
SIM plan will be sent to the registered email address. The SIM card is a rental
|
|
||||||
piece of hardware and must be returned to Assist Solutions upon cancellation.
|
|
||||||
The cancellation request through this website retains to your SIM subscriptions
|
|
||||||
only. To cancel any other services with Assist Solutions (home internet etc.)
|
|
||||||
please contact Assist Solutions at info@asolutions.co.jp
|
|
||||||
</Notice>
|
|
||||||
|
|
||||||
<Notice title="[Minimum Contract Term]">
|
|
||||||
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
|
|
||||||
month is not included in the minimum term of three months; ie. sign-up in
|
|
||||||
January = minimum term is February, March, April). If the minimum contract term
|
|
||||||
is not fulfilled, the monthly fees of the remaining months will be charged upon
|
|
||||||
cancellation.
|
|
||||||
</Notice>
|
|
||||||
|
|
||||||
<Notice title="[Cancellation of Option Services (for Data+SMS/Voice Plan)]">
|
|
||||||
Cancellation of option services only (Voice Mail, Call Waiting) while keeping
|
|
||||||
the base plan active is not possible from this online form. Please contact
|
|
||||||
Assist Solutions Customer Support (info@asolutions.co.jp) for more information.
|
|
||||||
Upon cancelling the base plan, all additional options associated with the
|
|
||||||
requested SIM plan will be cancelled.
|
|
||||||
</Notice>
|
|
||||||
|
|
||||||
<Notice title="[MNP Transfer (for Data+SMS/Voice Plan)]">
|
|
||||||
Upon cancellation the SIM phone number will be lost. In order to keep the phone
|
|
||||||
number active to be used with a different cellular provider, a request for an
|
|
||||||
MNP transfer (administrative fee ¥1,000+tax) is necessary. The MNP cannot be
|
|
||||||
requested from this online form. Please contact Assist Solutions Customer
|
|
||||||
Support (info@asolutions.co.jp) for more information.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 bg-muted border border-border rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="acceptTerms"
|
|
||||||
type="checkbox"
|
|
||||||
checked={acceptTerms}
|
|
||||||
onChange={e => setAcceptTerms(e.target.checked)}
|
|
||||||
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<label htmlFor="acceptTerms" className="text-sm text-foreground/80">
|
|
||||||
I have read and accepted the conditions above.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="confirmMonthEnd"
|
|
||||||
type="checkbox"
|
|
||||||
checked={confirmMonthEnd}
|
|
||||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
|
||||||
disabled={!selectedMonth}
|
|
||||||
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<label htmlFor="confirmMonthEnd" className="text-sm text-foreground/80">
|
|
||||||
I would like to cancel my SonixNet SIM subscription at the end of{" "}
|
|
||||||
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setStep(1)}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button disabled={!canProceedStep3} onClick={() => setStep(3)}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Voice SIM Notice */}
|
|
||||||
<div className="bg-info-soft border border-info/25 rounded-lg p-4">
|
|
||||||
<div className="text-sm font-semibold text-foreground mb-2">
|
|
||||||
For Voice-enabled SIM subscriptions:
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Calling charges are post payment. Your bill for the final month's calling
|
|
||||||
charges will be charged on your credit card on file during the first week of the
|
|
||||||
second month after the cancellation.
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground mt-2">
|
|
||||||
If you would like to make the payment with a different credit card, please
|
|
||||||
contact Assist Solutions at info@asolutions.co.jp
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Registered Email */}
|
|
||||||
<div className="text-sm text-foreground/80">
|
|
||||||
Your registered email address is:{" "}
|
|
||||||
<span className="font-medium">{preview?.customerEmail || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
You will receive a cancellation confirmation email. If you would like to receive
|
|
||||||
this email on a different address, please enter the address below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alternative Email */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
||||||
Email address:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
value={alternativeEmail}
|
|
||||||
onChange={e => setAlternativeEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
||||||
(Confirm):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
value={alternativeEmail2}
|
|
||||||
onChange={e => setAlternativeEmail2(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{emailProvided && !emailValid && (
|
|
||||||
<div className="text-xs text-danger">
|
|
||||||
Please enter a valid email address in both fields.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{emailProvided && emailValid && !emailsMatch && (
|
|
||||||
<div className="text-xs text-danger">Email addresses do not match.</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
||||||
If you have any other questions/comments/requests regarding your cancellation,
|
|
||||||
please note them below and an Assist Solutions staff will contact you shortly.
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
|
||||||
rows={4}
|
|
||||||
value={comments}
|
|
||||||
onChange={e => setComments(e.target.value)}
|
|
||||||
placeholder="Optional: Enter any questions or requests here."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Final Warning */}
|
|
||||||
<div className="bg-danger-soft border border-danger/25 rounded-lg p-4">
|
|
||||||
<div className="text-sm font-semibold text-foreground mb-1">
|
|
||||||
Your cancellation request is not confirmed yet.
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
This is the final page. To finalize your cancellation request please proceed
|
|
||||||
from REQUEST CANCELLATION below.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setStep(2)}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
void submit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading || !canProceedStep3}
|
|
||||||
loading={loading}
|
|
||||||
loadingText="Processing…"
|
|
||||||
>
|
|
||||||
REQUEST CANCELLATION
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
}
|
||||||
</PageLayout>
|
summaryContent={
|
||||||
|
<CancellationSummary
|
||||||
|
items={[{ label: "SIM Number", value: preview?.simNumber || "—" }]}
|
||||||
|
selectedMonth={selectedMonthLabel || "the selected month"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
step3ExtraContent={
|
||||||
|
<InfoNotice title="Voice-enabled SIM Notice">
|
||||||
|
Calling charges are post-paid. Final month charges will be billed during the first week of
|
||||||
|
the second month after cancellation.
|
||||||
|
</InfoNotice>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page.
|
2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page.
|
||||||
|
|
||||||
3. **Opportunity Lifecycle Fields Exist** - `Portal_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working.
|
3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working.
|
||||||
|
|
||||||
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification.
|
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification.
|
||||||
|
|
||||||
@ -484,7 +484,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
|||||||
| `StageName` | Stage | Picklist | Portal/Agent | Throughout lifecycle |
|
| `StageName` | Stage | Picklist | Portal/Agent | Throughout lifecycle |
|
||||||
| `CommodityType` | Commodity Type | Picklist | Portal | Creation |
|
| `CommodityType` | Commodity Type | Picklist | Portal | Creation |
|
||||||
| `Application_Stage__c` | Application Stage | Picklist | Portal | Creation (INTRO-1) |
|
| `Application_Stage__c` | Application Stage | Picklist | Portal | Creation (INTRO-1) |
|
||||||
| `Portal_Source__c` | Portal Source | Picklist | Portal | Creation |
|
| `Opportunity_Source__c` | Opportunity Source | Picklist | Portal | Creation |
|
||||||
| `WHMCS_Service_ID__c` | WHMCS Service ID | Number | Portal | After provisioning |
|
| `WHMCS_Service_ID__c` | WHMCS Service ID | Number | Portal | After provisioning |
|
||||||
| `CancellationNotice__c` | Cancellation Notice | Picklist | Portal | Cancellation request |
|
| `CancellationNotice__c` | Cancellation Notice | Picklist | Portal | Cancellation request |
|
||||||
| `ScheduledCancellationDateAndTime__c` | Scheduled Cancellation | DateTime | Portal | Cancellation request |
|
| `ScheduledCancellationDateAndTime__c` | Scheduled Cancellation | DateTime | Portal | Cancellation request |
|
||||||
@ -666,7 +666,7 @@ LONG TERM:
|
|||||||
|
|
||||||
**Status:** ✅ Confirmed fields exist:
|
**Status:** ✅ Confirmed fields exist:
|
||||||
|
|
||||||
- `Portal_Source__c` - Picklist with portal values
|
- `Opportunity_Source__c` - Picklist with portal values
|
||||||
- `WHMCS_Service_ID__c` - Number field for WHMCS linking
|
- `WHMCS_Service_ID__c` - Number field for WHMCS linking
|
||||||
|
|
||||||
**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder).
|
**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder).
|
||||||
|
|||||||
@ -88,21 +88,37 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
|
|
||||||
### Opportunity Fields (Existing)
|
### Opportunity Fields (Existing)
|
||||||
|
|
||||||
| Field | API Name | Purpose |
|
**Core Fields:**
|
||||||
| ---------------------- | ------------------------------------- | --------------------------------------------------------------- |
|
|
||||||
| Stage | `StageName` | Introduction, Ready, Post Processing, Active, △Cancelling, etc. |
|
|
||||||
| Commodity Type | `CommodityType` | Personal SonixNet Home Internet, SIM, VPN |
|
|
||||||
| Application Stage | `Application_Stage__c` | INTRO-1 (for portal) |
|
|
||||||
| Cancellation Notice | `CancellationNotice__c` | 有, 未, 不要, 移転 |
|
|
||||||
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | End of cancellation month |
|
|
||||||
| Line Return Status | `LineReturn__c` | NotYet, SentKit, Returned, etc. |
|
|
||||||
|
|
||||||
### New Opportunity Fields (To Create)
|
| Field | API Name | Purpose |
|
||||||
|
| ----------------- | ---------------------- | --------------------------------------------------------------- |
|
||||||
|
| Stage | `StageName` | Introduction, Ready, Post Processing, Active, △Cancelling, etc. |
|
||||||
|
| Commodity Type | `CommodityType` | Personal SonixNet Home Internet, SIM, VPN |
|
||||||
|
| Application Stage | `Application_Stage__c` | INTRO-1 (for portal) |
|
||||||
|
|
||||||
| Field | API Name | Type | Purpose |
|
**Internet Cancellation Fields:**
|
||||||
| ---------------- | --------------------- | -------- | -------------------------------- |
|
|
||||||
| Portal Source | `Portal_Source__c` | Picklist | How Opportunity was created |
|
| Field | API Name | Purpose |
|
||||||
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS after provisioning |
|
| ---------------------- | ------------------------------------- | ------------------------------ |
|
||||||
|
| Cancellation Notice | `CancellationNotice__c` | 有, 未, 不要, 移転 |
|
||||||
|
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | End of cancellation month |
|
||||||
|
| Line Return Status | `LineReturn__c` | NotYet, SentKit, Returned, etc |
|
||||||
|
|
||||||
|
**SIM Cancellation Fields:**
|
||||||
|
|
||||||
|
| Field | API Name | Purpose |
|
||||||
|
| -------------------------- | ---------------------------------------- | ----------------------- |
|
||||||
|
| SIM Cancellation Notice | `SIMCancellationNotice__c` | 有, 未, 不要 |
|
||||||
|
| SIM Scheduled Cancellation | `SIMScheduledCancellationDateAndTime__c` | End of SIM cancellation |
|
||||||
|
|
||||||
|
_Note: SIM customer comments are stored on the Cancellation Case, same as Internet._
|
||||||
|
|
||||||
|
### Portal Integration Fields (Existing)
|
||||||
|
|
||||||
|
| Field | API Name | Type | Purpose |
|
||||||
|
| ------------------ | ----------------------- | -------- | -------------------------------- |
|
||||||
|
| Opportunity Source | `Opportunity_Source__c` | Picklist | How Opportunity was created |
|
||||||
|
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS after provisioning |
|
||||||
|
|
||||||
### Order Fields (Existing)
|
### Order Fields (Existing)
|
||||||
|
|
||||||
@ -202,7 +218,7 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
│ │ If not → Create new: │ │
|
│ │ If not → Create new: │ │
|
||||||
│ │ - Stage: Introduction │ │
|
│ │ - Stage: Introduction │ │
|
||||||
│ │ - CommodityType: Personal SonixNet Home Internet │ │
|
│ │ - CommodityType: Personal SonixNet Home Internet │ │
|
||||||
│ │ - Portal_Source__c: Portal - Internet Eligibility Request │ │
|
│ │ - Opportunity_Source__c: Portal - Internet Eligibility Request│ │
|
||||||
│ │ - Application_Stage__c: INTRO-1 │ │
|
│ │ - Application_Stage__c: INTRO-1 │ │
|
||||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
@ -359,13 +375,15 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
|
|
||||||
## Cancellation Flow
|
## Cancellation Flow
|
||||||
|
|
||||||
### Always Create Case
|
The cancellation process differs between Internet and SIM services:
|
||||||
|
|
||||||
For **every** cancellation request, create a Case (notification to CS):
|
### Internet Cancellation Flow
|
||||||
|
|
||||||
|
For Internet cancellation, we create a Case for CS workflow and update the Opportunity:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
│ CANCELLATION FLOW │
|
│ INTERNET CANCELLATION FLOW │
|
||||||
├─────────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ 1. CUSTOMER SUBMITS CANCELLATION FORM │
|
│ 1. CUSTOMER SUBMITS CANCELLATION FORM │
|
||||||
@ -376,14 +394,9 @@ For **every** cancellation request, create a Case (notification to CS):
|
|||||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Case.Type = "Cancellation Request" │ │
|
│ │ Case.Type = "Cancellation Request" │ │
|
||||||
│ │ Case.AccountId = customer's account │ │
|
│ │ Case.AccountId = customer's account │ │
|
||||||
│ │ Case.Subject = "Cancellation Request - {Product}" │ │
|
│ │ Case.Subject = "Cancellation Request - Internet ({month})" │ │
|
||||||
│ │ Case.Description = ALL form data: │ │
|
│ │ Case.Description = ALL form data │ │
|
||||||
│ │ - WHMCS Service ID │ │
|
|
||||||
│ │ - Cancellation month │ │
|
|
||||||
│ │ - Alternative email (if provided) │ │
|
|
||||||
│ │ - Customer comments (if provided) │ │
|
|
||||||
│ │ Case.OpportunityId = linked Opportunity (if found) │ │
|
│ │ Case.OpportunityId = linked Opportunity (if found) │ │
|
||||||
│ │ Case.Status = "New" │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ 3. IF OPPORTUNITY IS LINKED (via WHMCS_Service_ID__c) │
|
│ 3. IF OPPORTUNITY IS LINKED (via WHMCS_Service_ID__c) │
|
||||||
@ -393,13 +406,47 @@ For **every** cancellation request, create a Case (notification to CS):
|
|||||||
│ - CancellationNotice__c = "有" │
|
│ - CancellationNotice__c = "有" │
|
||||||
│ - LineReturn__c = "NotYet" │
|
│ - LineReturn__c = "NotYet" │
|
||||||
│ │
|
│ │
|
||||||
│ 4. IF NOT LINKED (Legacy) │
|
│ 4. SEND CONFIRMATION EMAIL │
|
||||||
│ └─ Case contains all info for CS to process │
|
│ └─ Customer receives confirmation with cancellation details │
|
||||||
│ └─ CS will manually find and update correct Opportunity │
|
|
||||||
│ │
|
│ │
|
||||||
│ 5. CUSTOMER SEES │
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
│ └─ If linked: Cancellation status from Opportunity │
|
```
|
||||||
│ └─ If not linked: "Request received, we'll confirm by email" │
|
|
||||||
|
### SIM Cancellation Flow
|
||||||
|
|
||||||
|
For SIM cancellation, we call Freebit API, create a Case, and update the Opportunity:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SIM CANCELLATION FLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. CUSTOMER SUBMITS CANCELLATION FORM │
|
||||||
|
│ └─ Selects month (25th rule applies) │
|
||||||
|
│ └─ Sees minimum contract warning if applicable │
|
||||||
|
│ │
|
||||||
|
│ 2. CALL FREEBIT PA02-04 API │
|
||||||
|
│ └─ Cancel account with runDate = 1st of next month │
|
||||||
|
│ └─ Cancellation takes effect at end of selected month │
|
||||||
|
│ │
|
||||||
|
│ 3. CREATE CANCELLATION CASE (same as Internet) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Case.Type = "Cancellation Request" │ │
|
||||||
|
│ │ Case.AccountId = customer's account │ │
|
||||||
|
│ │ Case.Subject = "Cancellation Request - SIM ({month})" │ │
|
||||||
|
│ │ Case.Description = ALL form data (SIM #, comments, etc.) │ │
|
||||||
|
│ │ Case.OpportunityId = linked Opportunity (if found) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 4. IF OPPORTUNITY IS LINKED (via WHMCS_Service_ID__c) │
|
||||||
|
│ └─ Update Opportunity: │
|
||||||
|
│ - Stage = "△Cancelling" │
|
||||||
|
│ - SIMScheduledCancellationDateAndTime__c = end of month │
|
||||||
|
│ - SIMCancellationNotice__c = "有" │
|
||||||
|
│ │
|
||||||
|
│ 5. SEND CONFIRMATION EMAILS │
|
||||||
|
│ └─ Admin notification with API results │
|
||||||
|
│ └─ Customer confirmation with cancellation details │
|
||||||
│ │
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@ -415,14 +462,25 @@ On/After 25th → Must select NEXT month or later
|
|||||||
|
|
||||||
### Cancellation Data Location
|
### Cancellation Data Location
|
||||||
|
|
||||||
| Data | Where It Goes | Why |
|
**Internet Cancellation:**
|
||||||
| ----------------- | -------------------- | ------------------------- |
|
|
||||||
| Scheduled date | Opportunity | Lifecycle tracking |
|
| Data | Where It Goes | Why |
|
||||||
| Notice status | Opportunity | Lifecycle tracking |
|
| ----------------- | ------------- | ------------------------ |
|
||||||
| Return status | Opportunity | Lifecycle tracking |
|
| Scheduled date | Opportunity | Lifecycle tracking |
|
||||||
| Customer comments | **Case** | Not needed on Opp |
|
| Notice status | Opportunity | Lifecycle tracking |
|
||||||
| Alternative email | **Case** | Not needed on Opp |
|
| Return status | Opportunity | Equipment tracking (ONU) |
|
||||||
| WHMCS Service ID | Case (for reference) | Helps CS identify service |
|
| Customer comments | **Case** | Details for CS |
|
||||||
|
| WHMCS Service ID | Case | Service identification |
|
||||||
|
|
||||||
|
**SIM Cancellation:**
|
||||||
|
|
||||||
|
| Data | Where It Goes | Why |
|
||||||
|
| ----------------- | ------------- | ------------------------- |
|
||||||
|
| Scheduled date | Opportunity | Lifecycle tracking |
|
||||||
|
| Notice status | Opportunity | Lifecycle tracking |
|
||||||
|
| Customer comments | **Case** | Same as Internet |
|
||||||
|
| SIM #, Serial # | Case | Service identification |
|
||||||
|
| WHMCS Service ID | Case | Helps CS identify service |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -435,18 +493,27 @@ On/After 25th → Must select NEXT month or later
|
|||||||
- [x] Opportunity Stage picklist
|
- [x] Opportunity Stage picklist
|
||||||
- [x] CommodityType field
|
- [x] CommodityType field
|
||||||
- [x] Application_Stage\_\_c
|
- [x] Application_Stage\_\_c
|
||||||
- [x] CancellationNotice\_\_c
|
|
||||||
- [x] LineReturn\_\_c
|
|
||||||
- [x] ScheduledCancellationDateAndTime\_\_c
|
|
||||||
- [x] Account Internet eligibility fields
|
- [x] Account Internet eligibility fields
|
||||||
- [x] Account ID verification fields
|
- [x] Account ID verification fields
|
||||||
- [x] Case.OpportunityId (standard lookup)
|
- [x] Case.OpportunityId (standard lookup)
|
||||||
- [x] Order.OpportunityId (standard lookup)
|
- [x] Order.OpportunityId (standard lookup)
|
||||||
|
|
||||||
**Opportunity Fields Required (Portal writes these):**
|
**Internet Cancellation Fields (Existing):**
|
||||||
|
|
||||||
- [ ] `Portal_Source__c` picklist (used to track how the Opportunity was created)
|
- [x] `CancellationNotice__c` - Internet cancellation notice status
|
||||||
- [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations)
|
- [x] `LineReturn__c` - Equipment return status
|
||||||
|
- [x] `ScheduledCancellationDateAndTime__c` - Internet cancellation date
|
||||||
|
|
||||||
|
**SIM Cancellation Fields (Existing):**
|
||||||
|
|
||||||
|
- [x] `SIMCancellationNotice__c` - SIM cancellation notice status
|
||||||
|
- [x] `SIMScheduledCancellationDateAndTime__c` - SIM cancellation date
|
||||||
|
- _Note: Customer comments stored on Case, same as Internet_
|
||||||
|
|
||||||
|
**Portal Integration Fields (Existing):**
|
||||||
|
|
||||||
|
- [x] `Opportunity_Source__c` picklist (tracks how Opportunity was created)
|
||||||
|
- [x] `WHMCS_Service_ID__c` number field (links WHMCS → Salesforce for cancellations)
|
||||||
|
|
||||||
### WHMCS Admin Tasks
|
### WHMCS Admin Tasks
|
||||||
|
|
||||||
|
|||||||
@ -174,15 +174,29 @@ Opportunities track the customer lifecycle from lead to active service.
|
|||||||
|
|
||||||
#### Custom Fields Required
|
#### Custom Fields Required
|
||||||
|
|
||||||
| Field | API Name | Type | Purpose |
|
| Field | API Name | Type | Purpose |
|
||||||
| ---------------------- | ------------------------------------- | -------- | --------------------------- |
|
| ------------------ | ----------------------- | -------- | --------------------------- |
|
||||||
| Commodity Type | `CommodityType` | Picklist | Product type |
|
| Commodity Type | `CommodityType` | Picklist | Product type |
|
||||||
| Application Stage | `Application_Stage__c` | Picklist | Internal CS workflow |
|
| Application Stage | `Application_Stage__c` | Picklist | Internal CS workflow |
|
||||||
| Cancellation Notice | `CancellationNotice__c` | Picklist | Cancellation status |
|
| Opportunity Source | `Opportunity_Source__c` | Picklist | How opportunity was created |
|
||||||
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | DateTime | Cancellation date |
|
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS service |
|
||||||
| Line Return Status | `LineReturn__c` | Picklist | Equipment return status |
|
|
||||||
| Portal Source | `Portal_Source__c` | Picklist | How opportunity was created |
|
**Internet Cancellation Fields:**
|
||||||
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS service |
|
|
||||||
|
| Field | API Name | Type | Purpose |
|
||||||
|
| ---------------------- | ------------------------------------- | -------- | ----------------------------- |
|
||||||
|
| Cancellation Notice | `CancellationNotice__c` | Picklist | Internet cancellation status |
|
||||||
|
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | DateTime | Internet cancellation date |
|
||||||
|
| Line Return Status | `LineReturn__c` | Picklist | Equipment return status (ONU) |
|
||||||
|
|
||||||
|
**SIM Cancellation Fields:**
|
||||||
|
|
||||||
|
| Field | API Name | Type | Purpose |
|
||||||
|
| -------------------------- | ---------------------------------------- | -------- | ----------------------- |
|
||||||
|
| SIM Cancellation Notice | `SIMCancellationNotice__c` | Picklist | SIM cancellation status |
|
||||||
|
| SIM Scheduled Cancellation | `SIMScheduledCancellationDateAndTime__c` | DateTime | SIM cancellation date |
|
||||||
|
|
||||||
|
_Note: SIM customer comments are stored on the Cancellation Case, same as Internet._
|
||||||
|
|
||||||
**Stage Picklist Values (Customer Journey):**
|
**Stage Picklist Values (Customer Journey):**
|
||||||
|
|
||||||
@ -201,7 +215,7 @@ Opportunities track the customer lifecycle from lead to active service.
|
|||||||
- `SIM`
|
- `SIM`
|
||||||
- `VPN`
|
- `VPN`
|
||||||
|
|
||||||
**Portal Source Picklist Values:**
|
**Opportunity Source Picklist Values:**
|
||||||
|
|
||||||
- `Portal - Internet Eligibility Request`
|
- `Portal - Internet Eligibility Request`
|
||||||
- `Portal - Order Placement`
|
- `Portal - Order Placement`
|
||||||
|
|||||||
@ -189,7 +189,7 @@ export const PORTAL_DEFAULT_COMMODITY_TYPES = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sources from which Opportunities are created (Portal_Source__c)
|
* Sources from which Opportunities are created (Opportunity_Source__c)
|
||||||
*/
|
*/
|
||||||
export const OPPORTUNITY_SOURCE = {
|
export const OPPORTUNITY_SOURCE = {
|
||||||
INTERNET_ELIGIBILITY: "Portal - Internet Eligibility Request",
|
INTERNET_ELIGIBILITY: "Portal - Internet Eligibility Request",
|
||||||
@ -358,10 +358,10 @@ export interface CancellationFormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancellation data to populate on Opportunity (transformed from form)
|
* Cancellation data to populate on Opportunity for Internet services
|
||||||
* Only core lifecycle fields - details go on Cancellation Case
|
* Only core lifecycle fields - details go on Cancellation Case
|
||||||
*/
|
*/
|
||||||
export interface CancellationOpportunityData {
|
export interface InternetCancellationOpportunityData {
|
||||||
/** End of cancellation month (YYYY-MM-DD format) */
|
/** End of cancellation month (YYYY-MM-DD format) */
|
||||||
scheduledCancellationDate: string;
|
scheduledCancellationDate: string;
|
||||||
|
|
||||||
@ -372,6 +372,36 @@ export interface CancellationOpportunityData {
|
|||||||
lineReturnStatus: LineReturnStatusValue;
|
lineReturnStatus: LineReturnStatusValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIM cancellation notice values (SIMCancellationNotice__c)
|
||||||
|
* Tracks whether SIM cancellation form has been received
|
||||||
|
*/
|
||||||
|
export const SIM_CANCELLATION_NOTICE = {
|
||||||
|
RECEIVED: "有", // Form received
|
||||||
|
NOT_YET: "未", // Not yet received (default)
|
||||||
|
NOT_REQUIRED: "不要", // Not required
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SimCancellationNoticeValue =
|
||||||
|
(typeof SIM_CANCELLATION_NOTICE)[keyof typeof SIM_CANCELLATION_NOTICE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation data to populate on Opportunity for SIM services
|
||||||
|
* NOTE: Customer comments go on the Cancellation Case, not Opportunity (same as Internet)
|
||||||
|
*/
|
||||||
|
export interface SimCancellationOpportunityData {
|
||||||
|
/** End of cancellation month (YYYY-MM-DD format) */
|
||||||
|
scheduledCancellationDate: string;
|
||||||
|
|
||||||
|
/** SIM Cancellation notice status (always 有 from portal) */
|
||||||
|
cancellationNotice: SimCancellationNoticeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use InternetCancellationOpportunityData or SimCancellationOpportunityData
|
||||||
|
*/
|
||||||
|
export type CancellationOpportunityData = InternetCancellationOpportunityData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data to populate on Cancellation Case
|
* Data to populate on Cancellation Case
|
||||||
* This contains all customer-provided details
|
* This contains all customer-provided details
|
||||||
|
|||||||
@ -23,12 +23,15 @@ export {
|
|||||||
// Application stage constants
|
// Application stage constants
|
||||||
APPLICATION_STAGE,
|
APPLICATION_STAGE,
|
||||||
type ApplicationStageValue,
|
type ApplicationStageValue,
|
||||||
// Cancellation notice constants
|
// Internet Cancellation notice constants
|
||||||
CANCELLATION_NOTICE,
|
CANCELLATION_NOTICE,
|
||||||
type CancellationNoticeValue,
|
type CancellationNoticeValue,
|
||||||
// Line return status constants
|
// Line return status constants (Internet)
|
||||||
LINE_RETURN_STATUS,
|
LINE_RETURN_STATUS,
|
||||||
type LineReturnStatusValue,
|
type LineReturnStatusValue,
|
||||||
|
// SIM Cancellation notice constants
|
||||||
|
SIM_CANCELLATION_NOTICE,
|
||||||
|
type SimCancellationNoticeValue,
|
||||||
// Commodity type constants (existing Salesforce CommodityType field)
|
// Commodity type constants (existing Salesforce CommodityType field)
|
||||||
COMMODITY_TYPE,
|
COMMODITY_TYPE,
|
||||||
type CommodityTypeValue,
|
type CommodityTypeValue,
|
||||||
@ -74,6 +77,8 @@ export type {
|
|||||||
UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract,
|
UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract,
|
||||||
CancellationFormData as CancellationFormDataContract,
|
CancellationFormData as CancellationFormDataContract,
|
||||||
CancellationOpportunityData as CancellationOpportunityDataContract,
|
CancellationOpportunityData as CancellationOpportunityDataContract,
|
||||||
|
InternetCancellationOpportunityData,
|
||||||
|
SimCancellationOpportunityData,
|
||||||
CancellationCaseData as CancellationCaseDataContract,
|
CancellationCaseData as CancellationCaseDataContract,
|
||||||
CancellationEligibility as CancellationEligibilityContract,
|
CancellationEligibility as CancellationEligibilityContract,
|
||||||
CancellationMonthOption as CancellationMonthOptionContract,
|
CancellationMonthOption as CancellationMonthOptionContract,
|
||||||
|
|||||||
@ -381,7 +381,6 @@ export const simCancelFullRequestSchema = z
|
|||||||
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||||
confirmRead: z.boolean(),
|
confirmRead: z.boolean(),
|
||||||
confirmCancel: z.boolean(),
|
confirmCancel: z.boolean(),
|
||||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
|
||||||
comments: z.string().max(1000).optional(),
|
comments: z.string().max(1000).optional(),
|
||||||
})
|
})
|
||||||
.refine(data => data.confirmRead === true && data.confirmCancel === true, {
|
.refine(data => data.confirmRead === true && data.confirmCancel === true, {
|
||||||
|
|||||||
@ -168,7 +168,6 @@ export const internetCancelRequestSchema = z.object({
|
|||||||
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||||
confirmRead: z.boolean(),
|
confirmRead: z.boolean(),
|
||||||
confirmCancel: z.boolean(),
|
confirmCancel: z.boolean(),
|
||||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
|
||||||
comments: z.string().max(1000).optional(),
|
comments: z.string().max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user