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:
barsa 2026-01-05 16:32:45 +09:00
parent 2b001809c3
commit 6096c15659
16 changed files with 722 additions and 837 deletions

View File

@ -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" },

View File

@ -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;

View File

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

View File

@ -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", {

View File

@ -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"}`;
}
}

View File

@ -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
);
}
}
}

View File

@ -1 +1,2 @@
export * from "./SubscriptionStatusBadge";
export * from "./CancellationFlow";

View File

@ -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>
{error && !isBlockingError ? (
<AlertBanner variant="error" title="Unable to proceed" elevated>
{error}
</AlertBanner>
) : null}
{message ? (
<AlertBanner variant="success" title="Request submitted" elevated>
{message}
</AlertBanner>
) : null}
<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"
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) : "—"}
/>
<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();
<ServiceInfoItem label="Next Due" value={preview?.nextDueDate || "—"} />
</ServiceInfoGrid>
}
}}
disabled={loading || !canProceedStep3}
loading={loading}
loadingText="Processing…"
>
REQUEST CANCELLATION
</Button>
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>
<Notice title="Equipment Return">
Internet equipment (ONU, router) must be returned upon cancellation. Our team will
provide return instructions after processing your request.
</Notice>
<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>
</div>
)}
</SubCard>
</div>
) : null}
</PageLayout>
}
summaryContent={
<CancellationSummary
items={[{ label: "Service", value: preview?.productName || "—" }]}
selectedMonth={selectedMonthLabel || "the selected month"}
/>
}
/>
);
}

View File

@ -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>
{error && !isBlockingError ? (
<AlertBanner variant="error" title="Unable to proceed" elevated>
{error}
</AlertBanner>
) : null}
{message ? (
<AlertBanner variant="success" title="Request submitted" elevated>
{message}
</AlertBanner>
) : null}
<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>
{/* 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&apos;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}
backHref={`/account/subscriptions/${subscriptionId}#sim-management`}
backLabel="Back to SIM Management"
availableMonths={preview?.availableMonths || []}
customerEmail={preview?.customerEmail || ""}
loading={loading}
loadingText="Processing…"
>
REQUEST CANCELLATION
</Button>
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>
<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>
<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>
<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>
</div>
)}
</SubCard>
</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>
}
/>
);
}

View File

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

View File

@ -88,20 +88,36 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
### Opportunity Fields (Existing)
**Core Fields:**
| 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) |
**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. |
| Line Return Status | `LineReturn__c` | NotYet, SentKit, Returned, etc |
### New Opportunity Fields (To Create)
**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 |
| ---------------- | --------------------- | -------- | -------------------------------- |
| Portal Source | `Portal_Source__c` | Picklist | How Opportunity was created |
| ------------------ | ----------------------- | -------- | -------------------------------- |
| 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
**Internet Cancellation:**
| 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 |
| 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

View File

@ -175,15 +175,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 |
| 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):**
1. `Introduction` Initial inquiry/eligibility request
@ -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`

View File

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

View File

@ -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,

View File

@ -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, {

View File

@ -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(),
});