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.) */
|
||||
applicationStage: "Application_Stage__c",
|
||||
|
||||
// ---- Cancellation Fields (existing) ----
|
||||
// ---- Internet Cancellation Fields (existing) ----
|
||||
|
||||
/** Scheduled cancellation date/time (end of month) */
|
||||
/** Scheduled cancellation date/time (end of month) - for Internet */
|
||||
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
|
||||
|
||||
/** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) */
|
||||
/** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) - for Internet */
|
||||
cancellationNotice: "CancellationNotice__c",
|
||||
|
||||
/** Line return status for rental equipment */
|
||||
/** Line return status for rental equipment (ONU/router) - for Internet */
|
||||
lineReturnStatus: "LineReturn__c",
|
||||
|
||||
// ---- SIM Cancellation Fields (existing) ----
|
||||
|
||||
/** SIM Cancellation Form status: 有 (received), 未 (not yet), 不要 (not required) */
|
||||
simCancellationNotice: "SIMCancellationNotice__c",
|
||||
|
||||
/** SIM Scheduled cancellation date/time (end of month) */
|
||||
simScheduledCancellationDate: "SIMScheduledCancellationDateAndTime__c",
|
||||
|
||||
// NOTE: SIM cancellation comments go on the Cancellation Case, same as Internet
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
@ -85,27 +95,25 @@ export const OPPORTUNITY_COMMODITY_FIELD = {
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// New Custom Fields (to be created in Salesforce)
|
||||
// Portal Integration Custom Fields (Existing in Salesforce)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* New custom fields to be created in Salesforce.
|
||||
* Portal integration custom fields that exist in Salesforce.
|
||||
*
|
||||
* NOTE:
|
||||
* - CommodityType already exists - no need to create Product_Type__c
|
||||
* - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp)
|
||||
* - Orders link TO Opportunity via Order.OpportunityId (standard field)
|
||||
* - Alternative email and cancellation comments go on Case, not Opportunity
|
||||
*
|
||||
* TODO: Confirm with Salesforce admin and update API names after creation
|
||||
*/
|
||||
export const OPPORTUNITY_NEW_CUSTOM_FIELDS = {
|
||||
// ---- Source Field (to be created) ----
|
||||
export const OPPORTUNITY_PORTAL_INTEGRATION_FIELDS = {
|
||||
// ---- Source Field (existing) ----
|
||||
|
||||
/** Source of the Opportunity creation */
|
||||
source: "Portal_Source__c",
|
||||
source: "Opportunity_Source__c",
|
||||
|
||||
// ---- Integration Fields (to be created) ----
|
||||
// ---- Integration Fields (existing) ----
|
||||
|
||||
/** WHMCS Service ID (populated after provisioning) */
|
||||
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||
@ -125,7 +133,7 @@ export const OPPORTUNITY_FIELD_MAP = {
|
||||
...OPPORTUNITY_STANDARD_FIELDS,
|
||||
...OPPORTUNITY_EXISTING_CUSTOM_FIELDS,
|
||||
...OPPORTUNITY_COMMODITY_FIELD,
|
||||
...OPPORTUNITY_NEW_CUSTOM_FIELDS,
|
||||
...OPPORTUNITY_PORTAL_INTEGRATION_FIELDS,
|
||||
} as const;
|
||||
|
||||
export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP;
|
||||
@ -156,17 +164,20 @@ export const OPPORTUNITY_MATCH_QUERY_FIELDS = [
|
||||
export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [
|
||||
...OPPORTUNITY_MATCH_QUERY_FIELDS,
|
||||
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||
// Internet cancellation fields
|
||||
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
|
||||
OPPORTUNITY_FIELD_MAP.cancellationNotice,
|
||||
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
|
||||
// SIM cancellation fields
|
||||
OPPORTUNITY_FIELD_MAP.simCancellationNotice,
|
||||
OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate,
|
||||
OPPORTUNITY_FIELD_MAP.lastModifiedDate,
|
||||
// NOTE: Cancellation comments and alternative email are on the Cancellation Case
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fields to select for cancellation status display
|
||||
* Fields to select for Internet cancellation status display
|
||||
*/
|
||||
export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [
|
||||
export const OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS = [
|
||||
OPPORTUNITY_FIELD_MAP.id,
|
||||
OPPORTUNITY_FIELD_MAP.stage,
|
||||
OPPORTUNITY_FIELD_MAP.commodityType,
|
||||
@ -176,6 +187,23 @@ export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [
|
||||
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fields to select for SIM cancellation status display
|
||||
*/
|
||||
export const OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS = [
|
||||
OPPORTUNITY_FIELD_MAP.id,
|
||||
OPPORTUNITY_FIELD_MAP.stage,
|
||||
OPPORTUNITY_FIELD_MAP.commodityType,
|
||||
OPPORTUNITY_FIELD_MAP.simCancellationNotice,
|
||||
OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate,
|
||||
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* @deprecated Use OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS or OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS
|
||||
*/
|
||||
export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS;
|
||||
|
||||
// ============================================================================
|
||||
// Stage Picklist Reference (Existing Values)
|
||||
// ============================================================================
|
||||
@ -353,14 +381,13 @@ export const COMMODITY_TYPE_REFERENCE = {
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// New Picklist Values (to be created in Salesforce)
|
||||
// Opportunity Source Picklist Reference (Existing Values)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Source picklist values for Portal_Source__c field
|
||||
* (needs to be created in Salesforce)
|
||||
* Source picklist values for Opportunity_Source__c field (existing in Salesforce)
|
||||
*/
|
||||
export const PORTAL_SOURCE_PICKLIST = [
|
||||
export const OPPORTUNITY_SOURCE_PICKLIST = [
|
||||
{ value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" },
|
||||
{ value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" },
|
||||
{ value: "Portal - Order Placement", label: "Portal - Order Placement" },
|
||||
|
||||
@ -80,13 +80,17 @@ export const OPPORTUNITY_FIELDS = {
|
||||
applicationStage: "Application_Stage__c",
|
||||
|
||||
// Portal integration
|
||||
portalSource: "Portal_Source__c",
|
||||
opportunitySource: "Opportunity_Source__c",
|
||||
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||
|
||||
// Cancellation
|
||||
// Internet Cancellation
|
||||
cancellationNotice: "CancellationNotice__c",
|
||||
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
|
||||
lineReturn: "LineReturn__c",
|
||||
|
||||
// SIM Cancellation
|
||||
simCancellationNotice: "SIMCancellationNotice__c",
|
||||
simScheduledCancellationDate: "SIMScheduledCancellationDateAndTime__c",
|
||||
} as const;
|
||||
|
||||
export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS;
|
||||
|
||||
@ -45,7 +45,8 @@ import {
|
||||
OPPORTUNITY_FIELD_MAP,
|
||||
OPPORTUNITY_MATCH_QUERY_FIELDS,
|
||||
OPPORTUNITY_DETAIL_QUERY_FIELDS,
|
||||
OPPORTUNITY_CANCELLATION_QUERY_FIELDS,
|
||||
OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS,
|
||||
OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS,
|
||||
} from "../config/opportunity-field-map.js";
|
||||
|
||||
// ============================================================================
|
||||
@ -68,18 +69,41 @@ interface SalesforceOpportunityRecord {
|
||||
// Existing custom fields
|
||||
Application_Stage__c?: string;
|
||||
CommodityType?: string; // Existing product type field
|
||||
// Internet cancellation fields
|
||||
ScheduledCancellationDateAndTime__c?: string;
|
||||
CancellationNotice__c?: string;
|
||||
LineReturn__c?: string;
|
||||
// New custom fields (to be created)
|
||||
Portal_Source__c?: string;
|
||||
// SIM cancellation fields
|
||||
SIMCancellationNotice__c?: string;
|
||||
SIMScheduledCancellationDateAndTime__c?: string;
|
||||
// Portal integration custom fields (existing)
|
||||
Opportunity_Source__c?: string;
|
||||
WHMCS_Service_ID__c?: number;
|
||||
// Note: Cases and Orders link TO Opportunity via their OpportunityId field
|
||||
// Cancellation comments and alternative email are on the Cancellation Case
|
||||
// Relationship fields
|
||||
Account?: { Name?: string };
|
||||
}
|
||||
|
||||
type InternetCancellationOpportunityDataInput = Pick<
|
||||
CancellationOpportunityData,
|
||||
"scheduledCancellationDate" | "cancellationNotice" | "lineReturnStatus"
|
||||
>;
|
||||
|
||||
type SimCancellationOpportunityDataInput = Pick<
|
||||
CancellationOpportunityData,
|
||||
"scheduledCancellationDate" | "cancellationNotice"
|
||||
>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<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
|
||||
// ============================================================================
|
||||
@ -234,7 +258,7 @@ export class SalesforceOpportunityService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Opportunity with cancellation data from form submission
|
||||
* Update Opportunity with Internet cancellation data from form submission
|
||||
*
|
||||
* Sets:
|
||||
* - Stage to △Cancelling
|
||||
@ -246,26 +270,37 @@ export class SalesforceOpportunityService {
|
||||
* The Case is created separately and linked to this Opportunity via Case.OpportunityId.
|
||||
*
|
||||
* @param opportunityId - Salesforce Opportunity ID
|
||||
* @param data - Cancellation data (dates and status flags)
|
||||
* @param data - Internet cancellation data (dates and status flags)
|
||||
*/
|
||||
async updateCancellationData(
|
||||
async updateInternetCancellationData(
|
||||
opportunityId: string,
|
||||
data: CancellationOpportunityData
|
||||
data: InternetCancellationOpportunityDataInput
|
||||
): Promise<void> {
|
||||
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
|
||||
|
||||
this.logger.log("Updating Opportunity with cancellation data", {
|
||||
const safeData = (() => {
|
||||
const unknownData: unknown = data;
|
||||
if (!isRecord(unknownData)) throw new Error("Invalid cancellation data");
|
||||
|
||||
return {
|
||||
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
|
||||
cancellationNotice: requireStringField(unknownData, "cancellationNotice"),
|
||||
lineReturnStatus: requireStringField(unknownData, "lineReturnStatus"),
|
||||
};
|
||||
})();
|
||||
|
||||
this.logger.log("Updating Opportunity with Internet cancellation data", {
|
||||
opportunityId: safeOppId,
|
||||
scheduledDate: data.scheduledCancellationDate,
|
||||
cancellationNotice: data.cancellationNotice,
|
||||
scheduledDate: safeData.scheduledCancellationDate,
|
||||
cancellationNotice: safeData.cancellationNotice,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
Id: safeOppId,
|
||||
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
|
||||
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate,
|
||||
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice,
|
||||
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.lineReturnStatus,
|
||||
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate,
|
||||
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: safeData.cancellationNotice,
|
||||
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: safeData.lineReturnStatus,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -276,12 +311,12 @@ export class SalesforceOpportunityService {
|
||||
|
||||
await updateMethod(payload as Record<string, unknown> & { Id: string });
|
||||
|
||||
this.logger.log("Opportunity cancellation data updated successfully", {
|
||||
this.logger.log("Opportunity Internet cancellation data updated successfully", {
|
||||
opportunityId: safeOppId,
|
||||
scheduledDate: data.scheduledCancellationDate,
|
||||
scheduledDate: safeData.scheduledCancellationDate,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update Opportunity cancellation data", {
|
||||
this.logger.error("Failed to update Opportunity Internet cancellation data", {
|
||||
error: extractErrorMessage(error),
|
||||
opportunityId: safeOppId,
|
||||
});
|
||||
@ -289,6 +324,79 @@ export class SalesforceOpportunityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Opportunity with SIM cancellation data from form submission
|
||||
*
|
||||
* Sets:
|
||||
* - Stage to △Cancelling
|
||||
* - SIMScheduledCancellationDateAndTime__c
|
||||
* - SIMCancellationNotice__c to 有 (received)
|
||||
*
|
||||
* NOTE: Customer comments go on the Cancellation Case, not Opportunity (same as Internet).
|
||||
*
|
||||
* @param opportunityId - Salesforce Opportunity ID
|
||||
* @param data - SIM cancellation data (dates and status flags)
|
||||
*/
|
||||
async updateSimCancellationData(
|
||||
opportunityId: string,
|
||||
data: SimCancellationOpportunityDataInput
|
||||
): Promise<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
|
||||
// ==========================================================================
|
||||
@ -506,7 +614,13 @@ export class SalesforceOpportunityService {
|
||||
* @param whmcsServiceId - WHMCS Service ID
|
||||
* @returns Cancellation status details or null
|
||||
*/
|
||||
async getCancellationStatus(whmcsServiceId: number): Promise<{
|
||||
/**
|
||||
* Get Internet cancellation status for display in portal
|
||||
*
|
||||
* @param whmcsServiceId - WHMCS Service ID
|
||||
* @returns Internet cancellation status details or null
|
||||
*/
|
||||
async getInternetCancellationStatus(whmcsServiceId: number): Promise<{
|
||||
stage: OpportunityStageValue;
|
||||
isPending: boolean;
|
||||
isComplete: boolean;
|
||||
@ -514,7 +628,7 @@ export class SalesforceOpportunityService {
|
||||
rentalReturnStatus?: LineReturnStatusValue;
|
||||
} | null> {
|
||||
const soql = `
|
||||
SELECT ${OPPORTUNITY_CANCELLATION_QUERY_FIELDS.join(", ")}
|
||||
SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")}
|
||||
FROM Opportunity
|
||||
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
|
||||
ORDER BY CreatedDate DESC
|
||||
@ -523,7 +637,7 @@ export class SalesforceOpportunityService {
|
||||
|
||||
try {
|
||||
const result = (await this.sf.query(soql, {
|
||||
label: "opportunity:getCancellationStatus",
|
||||
label: "opportunity:getInternetCancellationStatus",
|
||||
})) as SalesforceResponse<SalesforceOpportunityRecord>;
|
||||
|
||||
const record = result.records?.[0];
|
||||
@ -541,7 +655,7 @@ export class SalesforceOpportunityService {
|
||||
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get cancellation status", {
|
||||
this.logger.error("Failed to get Internet cancellation status", {
|
||||
error: extractErrorMessage(error),
|
||||
whmcsServiceId,
|
||||
});
|
||||
@ -549,6 +663,66 @@ export class SalesforceOpportunityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM cancellation status for display in portal
|
||||
*
|
||||
* @param whmcsServiceId - WHMCS Service ID
|
||||
* @returns SIM cancellation status details or null
|
||||
*/
|
||||
async getSimCancellationStatus(whmcsServiceId: number): Promise<{
|
||||
stage: OpportunityStageValue;
|
||||
isPending: boolean;
|
||||
isComplete: boolean;
|
||||
scheduledEndDate?: string;
|
||||
} | null> {
|
||||
const soql = `
|
||||
SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")}
|
||||
FROM Opportunity
|
||||
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
|
||||
ORDER BY CreatedDate DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = (await this.sf.query(soql, {
|
||||
label: "opportunity:getSimCancellationStatus",
|
||||
})) as SalesforceResponse<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
|
||||
// ==========================================================================
|
||||
@ -724,7 +898,7 @@ export class SalesforceOpportunityService {
|
||||
closeDate: record.CloseDate,
|
||||
commodityType,
|
||||
productType: productType ?? undefined,
|
||||
source: record.Portal_Source__c as OpportunitySourceValue | undefined,
|
||||
source: record.Opportunity_Source__c as OpportunitySourceValue | undefined,
|
||||
applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined,
|
||||
isClosed: record.IsClosed,
|
||||
// Note: Related Cases and Orders are queried separately via their OpportunityId field
|
||||
|
||||
@ -27,7 +27,7 @@ import type {
|
||||
InternetCancelRequest,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import {
|
||||
type CancellationOpportunityData,
|
||||
type InternetCancellationOpportunityData,
|
||||
CANCELLATION_NOTICE,
|
||||
LINE_RETURN_STATUS,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
@ -237,10 +237,6 @@ export class InternetCancellationService {
|
||||
``,
|
||||
];
|
||||
|
||||
if (request.alternativeEmail) {
|
||||
descriptionLines.push(`Alternative Contact Email: ${request.alternativeEmail}`);
|
||||
}
|
||||
|
||||
if (request.comments) {
|
||||
descriptionLines.push(``, `Customer Comments:`, request.comments);
|
||||
}
|
||||
@ -282,13 +278,16 @@ export class InternetCancellationService {
|
||||
// Update Opportunity if found
|
||||
if (opportunityId) {
|
||||
try {
|
||||
const cancellationData: CancellationOpportunityData = {
|
||||
const cancellationData: InternetCancellationOpportunityData = {
|
||||
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
|
||||
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
|
||||
lineReturnStatus: LINE_RETURN_STATUS.NOT_YET,
|
||||
};
|
||||
|
||||
await this.opportunityService.updateCancellationData(opportunityId, cancellationData);
|
||||
await this.opportunityService.updateInternetCancellationData(
|
||||
opportunityId,
|
||||
cancellationData
|
||||
);
|
||||
|
||||
this.logger.log("Opportunity updated with cancellation data", {
|
||||
opportunityId,
|
||||
@ -327,15 +326,6 @@ Email: info@asolutions.co.jp`;
|
||||
subject: confirmationSubject,
|
||||
text: confirmationBody,
|
||||
});
|
||||
|
||||
// Send to alternative email if provided
|
||||
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
|
||||
await this.emailService.sendEmail({
|
||||
to: request.alternativeEmail,
|
||||
subject: confirmationSubject,
|
||||
text: confirmationBody,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail - Case was already created
|
||||
this.logger.error("Failed to send cancellation confirmation email", {
|
||||
|
||||
@ -154,7 +154,6 @@ Email: ${ADMIN_EMAIL}`;
|
||||
serialNumber?: string;
|
||||
cancellationMonth: string;
|
||||
registeredEmail: string;
|
||||
otherEmail?: string;
|
||||
comments?: string;
|
||||
}): string {
|
||||
return `The following SONIXNET SIM cancellation has been requested.
|
||||
@ -164,7 +163,6 @@ SIM #: ${params.simNumber}
|
||||
Serial #: ${params.serialNumber || "N/A"}
|
||||
Cancellation month: ${params.cancellationMonth}
|
||||
Registered email address: ${params.registeredEmail}
|
||||
Other email address: ${params.otherEmail || "N/A"}
|
||||
Comments: ${params.comments || "N/A"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||
import { SimValidationService } from "./sim-validation.service.js";
|
||||
import type {
|
||||
SimCancelRequest,
|
||||
@ -11,6 +13,8 @@ import type {
|
||||
SimCancellationMonth,
|
||||
SimCancellationPreview,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||
import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity";
|
||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
||||
@ -23,6 +27,8 @@ export class SimCancellationService {
|
||||
private readonly freebitService: FreebitOrchestratorService,
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly caseService: SalesforceCaseService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
@ -183,20 +189,31 @@ export class SimCancellationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service with full flow (PA02-04 and email notifications)
|
||||
* Cancel SIM service with full flow (PA02-04, Salesforce Case + Opportunity, and email notifications)
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate SIM subscription
|
||||
* 2. Call Freebit PA02-04 API to schedule cancellation
|
||||
* 3. Create Salesforce Case with all form details
|
||||
* 4. Update Salesforce Opportunity (if linked)
|
||||
* 5. Send email notifications
|
||||
*/
|
||||
async cancelSimFull(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimCancelFullRequest
|
||||
): Promise<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 account = validation.account;
|
||||
const simDetails = await this.freebitService.getSimDetails(account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||
const clientDetails = await this.whmcsClientService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName =
|
||||
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
@ -218,6 +235,14 @@ export class SimCancellationService {
|
||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const runDate = `${runYear}${runMonth}01`;
|
||||
|
||||
// Calculate the cancellation date (last day of selected month)
|
||||
const lastDayOfMonth = new Date(year, month, 0);
|
||||
const cancellationDate = [
|
||||
lastDayOfMonth.getFullYear(),
|
||||
String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"),
|
||||
String(lastDayOfMonth.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
|
||||
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
@ -236,12 +261,90 @@ export class SimCancellationService {
|
||||
runDate,
|
||||
});
|
||||
|
||||
// Find existing Opportunity for this subscription (by WHMCS Service ID)
|
||||
let opportunityId: string | null = null;
|
||||
try {
|
||||
opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
|
||||
} catch {
|
||||
this.logger.warn("Could not find Opportunity for SIM subscription", { subscriptionId });
|
||||
}
|
||||
|
||||
// Build description with all form data (same pattern as Internet)
|
||||
const descriptionLines = [
|
||||
`Cancellation Request from Portal`,
|
||||
``,
|
||||
`Product Type: SIM`,
|
||||
`SIM Number: ${account}`,
|
||||
`Serial Number: ${simDetails.iccid || "N/A"}`,
|
||||
`WHMCS Service ID: ${subscriptionId}`,
|
||||
`Cancellation Month: ${request.cancellationMonth}`,
|
||||
`Service End Date: ${cancellationDate}`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (request.comments) {
|
||||
descriptionLines.push(`Customer Comments:`, request.comments, ``);
|
||||
}
|
||||
|
||||
descriptionLines.push(`Submitted: ${new Date().toISOString()}`);
|
||||
|
||||
// Create Salesforce Case for cancellation (same as Internet)
|
||||
let caseId: string | undefined;
|
||||
try {
|
||||
const caseResult = await this.caseService.createCase({
|
||||
accountId: mapping.sfAccountId,
|
||||
opportunityId: opportunityId || undefined,
|
||||
subject: `Cancellation Request - SIM (${request.cancellationMonth})`,
|
||||
description: descriptionLines.join("\n"),
|
||||
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
|
||||
priority: "High",
|
||||
});
|
||||
caseId = caseResult.id;
|
||||
|
||||
this.logger.log("SIM cancellation case created", {
|
||||
caseId,
|
||||
opportunityId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't fail - Freebit API was already called successfully
|
||||
this.logger.error("Failed to create SIM cancellation Case", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Update Salesforce Opportunity (if linked via WHMCS_Service_ID__c)
|
||||
if (opportunityId) {
|
||||
try {
|
||||
const cancellationData = {
|
||||
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
|
||||
cancellationNotice: SIM_CANCELLATION_NOTICE.RECEIVED,
|
||||
};
|
||||
|
||||
await this.opportunityService.updateSimCancellationData(opportunityId, cancellationData);
|
||||
|
||||
this.logger.log("Opportunity updated with SIM cancellation data", {
|
||||
opportunityId,
|
||||
scheduledDate: cancellationDate,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't fail - Freebit API was already called successfully
|
||||
this.logger.warn("Failed to update Opportunity with SIM cancellation data", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
subscriptionId,
|
||||
opportunityId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.debug("No Opportunity linked to SIM subscription", { subscriptionId });
|
||||
}
|
||||
|
||||
try {
|
||||
await this.notifications.createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: `sim:${subscriptionId}:${runDate}`,
|
||||
sourceId: caseId || opportunityId || `sim:${subscriptionId}:${runDate}`,
|
||||
actionUrl: `/account/services/${subscriptionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
@ -261,7 +364,6 @@ export class SimCancellationService {
|
||||
serialNumber: simDetails.iccid,
|
||||
cancellationMonth: request.cancellationMonth,
|
||||
registeredEmail: customerEmail,
|
||||
otherEmail: request.alternativeEmail || undefined,
|
||||
comments: request.comments,
|
||||
});
|
||||
|
||||
@ -285,7 +387,7 @@ export class SimCancellationService {
|
||||
adminEmailBody
|
||||
);
|
||||
|
||||
// Send confirmation email to customer (and alternative if provided)
|
||||
// Send confirmation email to customer
|
||||
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
|
||||
const confirmationBody = `Dear ${customerName},
|
||||
|
||||
@ -305,14 +407,5 @@ Email: info@asolutions.co.jp`;
|
||||
confirmationSubject,
|
||||
confirmationBody
|
||||
);
|
||||
|
||||
// Send to alternative email if provided
|
||||
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
request.alternativeEmail,
|
||||
confirmationSubject,
|
||||
confirmationBody
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./SubscriptionStatusBadge";
|
||||
export * from "./CancellationFlow";
|
||||
|
||||
@ -1,53 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { internetActionsService } from "@/features/subscriptions/api/internet-actions.api";
|
||||
import type { InternetCancellationPreview } from "@customer-portal/domain/subscriptions";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { GlobeAltIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Step = 1 | 2 | 3;
|
||||
|
||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-foreground mb-2">{title}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
import {
|
||||
CancellationFlow,
|
||||
Notice,
|
||||
ServiceInfoGrid,
|
||||
ServiceInfoItem,
|
||||
CancellationSummary,
|
||||
} from "@/features/subscriptions/components/CancellationFlow";
|
||||
|
||||
export function InternetCancelContainer() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preview, setPreview] = useState<InternetCancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
||||
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
||||
const [comments, setComments] = useState<string>("");
|
||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreview = async () => {
|
||||
@ -63,50 +40,38 @@ export function InternetCancelContainer() {
|
||||
: "Unable to load cancellation information right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0;
|
||||
const emailValid =
|
||||
!emailProvided ||
|
||||
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
|
||||
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
|
||||
const canProceedStep2 = !!preview && !!selectedMonth;
|
||||
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const formatCurrency = (amount: number) => `¥${amount.toLocaleString()}`;
|
||||
|
||||
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
|
||||
const handleSubmit = async (data: {
|
||||
cancellationMonth: string;
|
||||
confirmRead: boolean;
|
||||
confirmCancel: boolean;
|
||||
comments?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return `¥${amount.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
|
||||
if (!selectedMonth) {
|
||||
setError("Please select a cancellation month before submitting.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Track selected month label for success message
|
||||
const monthInfo = preview?.availableMonths.find(m => m.value === data.cancellationMonth);
|
||||
setSelectedMonthLabel(monthInfo?.label || data.cancellationMonth);
|
||||
|
||||
try {
|
||||
await internetActionsService.submitCancellation(subscriptionId, {
|
||||
cancellationMonth: selectedMonth,
|
||||
confirmRead: acceptTerms,
|
||||
confirmCancel: confirmMonthEnd,
|
||||
alternativeEmail: alternativeEmail.trim() || undefined,
|
||||
comments: comments.trim() || undefined,
|
||||
cancellationMonth: data.cancellationMonth,
|
||||
confirmRead: data.confirmRead,
|
||||
confirmCancel: data.confirmCancel,
|
||||
comments: data.comments,
|
||||
});
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setSuccessMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(() => router.push(`/account/subscriptions/${subscriptionId}`), 2000);
|
||||
} catch (e: unknown) {
|
||||
setError(
|
||||
setFormError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
@ -114,18 +79,19 @@ export function InternetCancelContainer() {
|
||||
: "Unable to submit your cancellation right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isBlockingError = !loadingPreview && !preview && Boolean(error);
|
||||
const pageError = isBlockingError ? error : null;
|
||||
if (!preview && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
<CancellationFlow
|
||||
icon={<GlobeAltIcon />}
|
||||
title="Cancel Internet"
|
||||
description="Cancel your Internet subscription"
|
||||
title="Cancel Internet Service"
|
||||
description={preview?.productName || "Cancel your Internet subscription"}
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{
|
||||
@ -134,272 +100,52 @@ export function InternetCancelContainer() {
|
||||
},
|
||||
{ label: "Cancel" },
|
||||
]}
|
||||
loading={loadingPreview}
|
||||
error={pageError}
|
||||
>
|
||||
{preview ? (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href={`/account/subscriptions/${subscriptionId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
← Back to Subscription Details
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{[1, 2, 3].map(s => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-2 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-border"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Step {step} of 3</div>
|
||||
</div>
|
||||
backHref={`/account/subscriptions/${subscriptionId}`}
|
||||
backLabel="Back to Subscription"
|
||||
availableMonths={preview?.availableMonths || []}
|
||||
customerEmail={preview?.customerEmail || ""}
|
||||
loading={loading}
|
||||
error={!preview && error ? error : null}
|
||||
formError={preview ? formError : null}
|
||||
successMessage={successMessage}
|
||||
submitting={submitting}
|
||||
confirmMessage="Are you sure you want to cancel your Internet service? This will take effect at the end of {month}."
|
||||
onSubmit={handleSubmit}
|
||||
serviceInfo={
|
||||
<ServiceInfoGrid>
|
||||
<ServiceInfoItem label="Service" value={preview?.productName || "—"} />
|
||||
<ServiceInfoItem
|
||||
label="Monthly"
|
||||
value={preview?.billingAmount ? formatCurrency(preview.billingAmount) : "—"}
|
||||
/>
|
||||
<ServiceInfoItem label="Next Due" value={preview?.nextDueDate || "—"} />
|
||||
</ServiceInfoGrid>
|
||||
}
|
||||
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 ? (
|
||||
<AlertBanner variant="error" title="Unable to proceed" elevated>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{message ? (
|
||||
<AlertBanner variant="success" title="Request submitted" elevated>
|
||||
{message}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
<Notice title="Equipment Return">
|
||||
Internet equipment (ONU, router) must be returned upon cancellation. Our team will
|
||||
provide return instructions after processing your request.
|
||||
</Notice>
|
||||
|
||||
<SubCard>
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">Cancel Internet Service</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Cancel your Internet subscription. Please read all the information carefully before
|
||||
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>
|
||||
<Notice title="Final Billing">
|
||||
You will be billed through the end of your cancellation month. Any outstanding balance
|
||||
will be processed according to your billing cycle.
|
||||
</Notice>
|
||||
</div>
|
||||
) : null}
|
||||
</PageLayout>
|
||||
}
|
||||
summaryContent={
|
||||
<CancellationSummary
|
||||
items={[{ label: "Service", value: preview?.productName || "—" }]}
|
||||
selectedMonth={selectedMonthLabel || "the selected month"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,53 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
|
||||
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";
|
||||
|
||||
type Step = 1 | 2 | 3;
|
||||
|
||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-foreground mb-2">{title}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
import {
|
||||
CancellationFlow,
|
||||
Notice,
|
||||
InfoNotice,
|
||||
ServiceInfoGrid,
|
||||
ServiceInfoItem,
|
||||
CancellationSummary,
|
||||
MinimumContractWarning,
|
||||
} from "@/features/subscriptions/components/CancellationFlow";
|
||||
|
||||
export function SimCancelContainer() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preview, setPreview] = useState<SimCancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
||||
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
||||
const [comments, setComments] = useState<string>("");
|
||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreview = async () => {
|
||||
@ -63,49 +42,39 @@ export function SimCancelContainer() {
|
||||
: "Unable to load cancellation information right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0;
|
||||
const emailValid =
|
||||
!emailProvided ||
|
||||
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
|
||||
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
|
||||
const canProceedStep2 = !!preview && !!selectedMonth;
|
||||
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const handleSubmit = async (data: {
|
||||
cancellationMonth: string;
|
||||
confirmRead: boolean;
|
||||
confirmCancel: boolean;
|
||||
comments?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
|
||||
if (!selectedMonth) {
|
||||
setError("Please select a cancellation month before submitting.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Track selected month label for success message
|
||||
const monthInfo = preview?.availableMonths.find(m => m.value === data.cancellationMonth);
|
||||
setSelectedMonthLabel(monthInfo?.label || data.cancellationMonth);
|
||||
|
||||
try {
|
||||
await simActionsService.cancelFull(subscriptionId, {
|
||||
cancellationMonth: selectedMonth,
|
||||
confirmRead: acceptTerms,
|
||||
confirmCancel: confirmMonthEnd,
|
||||
alternativeEmail: alternativeEmail.trim() || undefined,
|
||||
comments: comments.trim() || undefined,
|
||||
cancellationMonth: data.cancellationMonth,
|
||||
confirmRead: data.confirmRead,
|
||||
confirmCancel: data.confirmCancel,
|
||||
comments: data.comments,
|
||||
});
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setSuccessMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(
|
||||
() => router.push(`/account/subscriptions/${subscriptionId}#sim-management`),
|
||||
2000
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
setError(
|
||||
setFormError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
@ -113,18 +82,21 @@ export function SimCancelContainer() {
|
||||
: "Unable to submit your cancellation right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isBlockingError = !loadingPreview && !preview && Boolean(error);
|
||||
const pageError = isBlockingError ? error : null;
|
||||
if (!preview && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
<CancellationFlow
|
||||
icon={<DevicePhoneMobileIcon />}
|
||||
title="Cancel SIM"
|
||||
description="Cancel your SIM subscription"
|
||||
title="Cancel SIM Service"
|
||||
description={
|
||||
preview?.simNumber ? `SIM: ${preview.simNumber}` : "Cancel your SIM subscription"
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{
|
||||
@ -133,300 +105,66 @@ export function SimCancelContainer() {
|
||||
},
|
||||
{ label: "Cancel SIM" },
|
||||
]}
|
||||
loading={loadingPreview}
|
||||
error={pageError}
|
||||
>
|
||||
{preview ? (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href={`/account/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{[1, 2, 3].map(s => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-2 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-border"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Step {step} of 3</div>
|
||||
</div>
|
||||
backHref={`/account/subscriptions/${subscriptionId}#sim-management`}
|
||||
backLabel="Back to SIM Management"
|
||||
availableMonths={preview?.availableMonths || []}
|
||||
customerEmail={preview?.customerEmail || ""}
|
||||
loading={loading}
|
||||
error={!preview && error ? error : null}
|
||||
formError={preview ? formError : null}
|
||||
successMessage={successMessage}
|
||||
submitting={submitting}
|
||||
confirmMessage="Are you sure you want to cancel your SIM subscription? This will take effect at the end of {month}."
|
||||
onSubmit={handleSubmit}
|
||||
warningBanner={
|
||||
preview?.isWithinMinimumTerm && preview.minimumContractEndDate ? (
|
||||
<MinimumContractWarning endDate={preview.minimumContractEndDate} />
|
||||
) : null
|
||||
}
|
||||
serviceInfo={
|
||||
<ServiceInfoGrid>
|
||||
<ServiceInfoItem label="SIM Number" value={preview?.simNumber || "—"} />
|
||||
<ServiceInfoItem label="Serial #" value={preview?.serialNumber || "—"} mono />
|
||||
<ServiceInfoItem label="Start Date" value={preview?.startDate || "—"} />
|
||||
</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 ? (
|
||||
<AlertBanner variant="error" title="Unable to proceed" elevated>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{message ? (
|
||||
<AlertBanner variant="success" title="Request submitted" elevated>
|
||||
{message}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
<Notice title="Minimum Contract Term">
|
||||
The SONIXNET SIM has a 3-month minimum contract term (sign-up month not included). Early
|
||||
cancellation will incur charges for remaining months.
|
||||
</Notice>
|
||||
|
||||
<SubCard>
|
||||
<h1 className="text-xl font-semibold text-foreground mb-2">Cancel SIM</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Cancel your SIM subscription. Please read all the information carefully before
|
||||
proceeding.
|
||||
</p>
|
||||
<Notice title="Option Services">
|
||||
Cancelling the base plan will also cancel all associated options (Voice Mail, Call
|
||||
Waiting). To cancel options only, please contact support.
|
||||
</Notice>
|
||||
|
||||
{/* Minimum Contract Warning */}
|
||||
{preview?.isWithinMinimumTerm && (
|
||||
<div className="bg-danger-soft border border-danger/25 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-semibold text-foreground mb-1">
|
||||
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>
|
||||
<Notice title="MNP Transfer">
|
||||
Your phone number will be lost upon cancellation. To keep the number via MNP transfer
|
||||
(¥1,000+tax), contact Assist Solutions before cancelling.
|
||||
</Notice>
|
||||
</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.
|
||||
|
||||
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.
|
||||
|
||||
@ -484,7 +484,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
||||
| `StageName` | Stage | Picklist | Portal/Agent | Throughout lifecycle |
|
||||
| `CommodityType` | Commodity Type | Picklist | Portal | Creation |
|
||||
| `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 |
|
||||
| `CancellationNotice__c` | Cancellation Notice | Picklist | Portal | Cancellation request |
|
||||
| `ScheduledCancellationDateAndTime__c` | Scheduled Cancellation | DateTime | Portal | Cancellation request |
|
||||
@ -666,7 +666,7 @@ LONG TERM:
|
||||
|
||||
**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
|
||||
|
||||
**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)
|
||||
|
||||
| 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) |
|
||||
| Cancellation Notice | `CancellationNotice__c` | 有, 未, 不要, 移転 |
|
||||
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | End of cancellation month |
|
||||
| Line Return Status | `LineReturn__c` | NotYet, SentKit, Returned, etc. |
|
||||
**Core Fields:**
|
||||
|
||||
### 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 |
|
||||
| ---------------- | --------------------- | -------- | -------------------------------- |
|
||||
| Portal Source | `Portal_Source__c` | Picklist | How Opportunity was created |
|
||||
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS after provisioning |
|
||||
**Internet Cancellation Fields:**
|
||||
|
||||
| Field | API Name | Purpose |
|
||||
| ---------------------- | ------------------------------------- | ------------------------------ |
|
||||
| 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)
|
||||
|
||||
@ -202,7 +218,7 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
│ │ If not → Create new: │ │
|
||||
│ │ - Stage: Introduction │ │
|
||||
│ │ - CommodityType: Personal SonixNet Home Internet │ │
|
||||
│ │ - Portal_Source__c: Portal - Internet Eligibility Request │ │
|
||||
│ │ - Opportunity_Source__c: Portal - Internet Eligibility Request│ │
|
||||
│ │ - Application_Stage__c: INTRO-1 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
@ -359,13 +375,15 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
|
||||
## 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 │
|
||||
@ -376,14 +394,9 @@ For **every** cancellation request, create a Case (notification to CS):
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Case.Type = "Cancellation Request" │ │
|
||||
│ │ Case.AccountId = customer's account │ │
|
||||
│ │ Case.Subject = "Cancellation Request - {Product}" │ │
|
||||
│ │ Case.Description = ALL form data: │ │
|
||||
│ │ - WHMCS Service ID │ │
|
||||
│ │ - Cancellation month │ │
|
||||
│ │ - Alternative email (if provided) │ │
|
||||
│ │ - Customer comments (if provided) │ │
|
||||
│ │ Case.Subject = "Cancellation Request - Internet ({month})" │ │
|
||||
│ │ Case.Description = ALL form data │ │
|
||||
│ │ Case.OpportunityId = linked Opportunity (if found) │ │
|
||||
│ │ Case.Status = "New" │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 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 = "有" │
|
||||
│ - LineReturn__c = "NotYet" │
|
||||
│ │
|
||||
│ 4. IF NOT LINKED (Legacy) │
|
||||
│ └─ Case contains all info for CS to process │
|
||||
│ └─ CS will manually find and update correct Opportunity │
|
||||
│ 4. SEND CONFIRMATION EMAIL │
|
||||
│ └─ Customer receives confirmation with cancellation details │
|
||||
│ │
|
||||
│ 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
|
||||
|
||||
| Data | Where It Goes | Why |
|
||||
| ----------------- | -------------------- | ------------------------- |
|
||||
| Scheduled date | Opportunity | Lifecycle tracking |
|
||||
| Notice status | Opportunity | Lifecycle tracking |
|
||||
| Return status | Opportunity | Lifecycle tracking |
|
||||
| Customer comments | **Case** | Not needed on Opp |
|
||||
| Alternative email | **Case** | Not needed on Opp |
|
||||
| WHMCS Service ID | Case (for reference) | Helps CS identify service |
|
||||
**Internet Cancellation:**
|
||||
|
||||
| Data | Where It Goes | Why |
|
||||
| ----------------- | ------------- | ------------------------ |
|
||||
| Scheduled date | Opportunity | Lifecycle tracking |
|
||||
| Notice status | Opportunity | Lifecycle tracking |
|
||||
| Return status | Opportunity | Equipment tracking (ONU) |
|
||||
| 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] CommodityType field
|
||||
- [x] Application_Stage\_\_c
|
||||
- [x] CancellationNotice\_\_c
|
||||
- [x] LineReturn\_\_c
|
||||
- [x] ScheduledCancellationDateAndTime\_\_c
|
||||
- [x] Account Internet eligibility fields
|
||||
- [x] Account ID verification fields
|
||||
- [x] Case.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)
|
||||
- [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations)
|
||||
- [x] `CancellationNotice__c` - Internet cancellation notice status
|
||||
- [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
|
||||
|
||||
|
||||
@ -174,15 +174,29 @@ Opportunities track the customer lifecycle from lead to active service.
|
||||
|
||||
#### Custom Fields Required
|
||||
|
||||
| Field | API Name | Type | Purpose |
|
||||
| ---------------------- | ------------------------------------- | -------- | --------------------------- |
|
||||
| Commodity Type | `CommodityType` | Picklist | Product type |
|
||||
| Application Stage | `Application_Stage__c` | Picklist | Internal CS workflow |
|
||||
| Cancellation Notice | `CancellationNotice__c` | Picklist | Cancellation status |
|
||||
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | DateTime | Cancellation date |
|
||||
| Line Return Status | `LineReturn__c` | Picklist | Equipment return status |
|
||||
| Portal Source | `Portal_Source__c` | Picklist | How opportunity was created |
|
||||
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS service |
|
||||
| Field | API Name | Type | Purpose |
|
||||
| ------------------ | ----------------------- | -------- | --------------------------- |
|
||||
| Commodity Type | `CommodityType` | Picklist | Product type |
|
||||
| Application Stage | `Application_Stage__c` | Picklist | Internal CS workflow |
|
||||
| Opportunity Source | `Opportunity_Source__c` | Picklist | How opportunity was created |
|
||||
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS service |
|
||||
|
||||
**Internet Cancellation Fields:**
|
||||
|
||||
| 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):**
|
||||
|
||||
@ -201,7 +215,7 @@ Opportunities track the customer lifecycle from lead to active service.
|
||||
- `SIM`
|
||||
- `VPN`
|
||||
|
||||
**Portal Source Picklist Values:**
|
||||
**Opportunity Source Picklist Values:**
|
||||
|
||||
- `Portal - Internet Eligibility Request`
|
||||
- `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 = {
|
||||
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
|
||||
*/
|
||||
export interface CancellationOpportunityData {
|
||||
export interface InternetCancellationOpportunityData {
|
||||
/** End of cancellation month (YYYY-MM-DD format) */
|
||||
scheduledCancellationDate: string;
|
||||
|
||||
@ -372,6 +372,36 @@ export interface CancellationOpportunityData {
|
||||
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
|
||||
* This contains all customer-provided details
|
||||
|
||||
@ -23,12 +23,15 @@ export {
|
||||
// Application stage constants
|
||||
APPLICATION_STAGE,
|
||||
type ApplicationStageValue,
|
||||
// Cancellation notice constants
|
||||
// Internet Cancellation notice constants
|
||||
CANCELLATION_NOTICE,
|
||||
type CancellationNoticeValue,
|
||||
// Line return status constants
|
||||
// Line return status constants (Internet)
|
||||
LINE_RETURN_STATUS,
|
||||
type LineReturnStatusValue,
|
||||
// SIM Cancellation notice constants
|
||||
SIM_CANCELLATION_NOTICE,
|
||||
type SimCancellationNoticeValue,
|
||||
// Commodity type constants (existing Salesforce CommodityType field)
|
||||
COMMODITY_TYPE,
|
||||
type CommodityTypeValue,
|
||||
@ -74,6 +77,8 @@ export type {
|
||||
UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract,
|
||||
CancellationFormData as CancellationFormDataContract,
|
||||
CancellationOpportunityData as CancellationOpportunityDataContract,
|
||||
InternetCancellationOpportunityData,
|
||||
SimCancellationOpportunityData,
|
||||
CancellationCaseData as CancellationCaseDataContract,
|
||||
CancellationEligibility as CancellationEligibilityContract,
|
||||
CancellationMonthOption as CancellationMonthOptionContract,
|
||||
|
||||
@ -381,7 +381,6 @@ export const simCancelFullRequestSchema = z
|
||||
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||
confirmRead: z.boolean(),
|
||||
confirmCancel: z.boolean(),
|
||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||
comments: z.string().max(1000).optional(),
|
||||
})
|
||||
.refine(data => data.confirmRead === true && data.confirmCancel === true, {
|
||||
|
||||
@ -168,7 +168,6 @@ export const internetCancelRequestSchema = z.object({
|
||||
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||
confirmRead: z.boolean(),
|
||||
confirmCancel: z.boolean(),
|
||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||
comments: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user