Implement Unified Cancellation Features for SIM and Internet Services
- Added new methods in SalesforceOpportunityService to retrieve cancellation statuses for both Internet and SIM services by Opportunity ID, enhancing cancellation handling. - Updated BFF module to include a new CancellationModule, improving service organization and modularity. - Refactored portal routes and components to unify cancellation navigation, streamlining user experience. - Introduced new domain schemas for unified cancellation previews, ensuring consistent data structure and validation across services. - Removed deprecated cancellation components from the portal, promoting cleaner code and improved maintainability.
This commit is contained in:
parent
922fd3dab0
commit
b19c213931
@ -663,6 +663,54 @@ export class SalesforceOpportunityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Internet cancellation status by Opportunity ID (direct lookup)
|
||||
*
|
||||
* Preferred when OpportunityId is already known (e.g., stored on WHMCS service custom fields).
|
||||
*/
|
||||
async getInternetCancellationStatusByOpportunityId(opportunityId: string): Promise<{
|
||||
stage: OpportunityStageValue;
|
||||
isPending: boolean;
|
||||
isComplete: boolean;
|
||||
scheduledEndDate?: string;
|
||||
rentalReturnStatus?: LineReturnStatusValue;
|
||||
} | null> {
|
||||
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
|
||||
const soql = `
|
||||
SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")}
|
||||
FROM Opportunity
|
||||
WHERE Id = '${safeOppId}'
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = (await this.sf.query(soql, {
|
||||
label: "opportunity:getInternetCancellationStatusByOpportunityId",
|
||||
})) 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.ScheduledCancellationDateAndTime__c,
|
||||
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get Internet cancellation status by Opportunity ID", {
|
||||
error: extractErrorMessage(error),
|
||||
opportunityId: safeOppId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM cancellation status for display in portal
|
||||
*
|
||||
@ -710,6 +758,52 @@ export class SalesforceOpportunityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM cancellation status by Opportunity ID (direct lookup)
|
||||
*
|
||||
* Preferred when OpportunityId is already known (e.g., stored on WHMCS service custom fields).
|
||||
*/
|
||||
async getSimCancellationStatusByOpportunityId(opportunityId: string): Promise<{
|
||||
stage: OpportunityStageValue;
|
||||
isPending: boolean;
|
||||
isComplete: boolean;
|
||||
scheduledEndDate?: string;
|
||||
} | null> {
|
||||
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
|
||||
const soql = `
|
||||
SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")}
|
||||
FROM Opportunity
|
||||
WHERE Id = '${safeOppId}'
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = (await this.sf.query(soql, {
|
||||
label: "opportunity:getSimCancellationStatusByOpportunityId",
|
||||
})) 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 by Opportunity ID", {
|
||||
error: extractErrorMessage(error),
|
||||
opportunityId: safeOppId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInternetCancellationStatus or getSimCancellationStatus
|
||||
*/
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import type { ServiceType, CancellationNotice } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
export const CANCELLATION_TERMS: Record<ServiceType, CancellationNotice[]> = {
|
||||
internet: [
|
||||
{
|
||||
title: "Cancellation Deadline",
|
||||
content:
|
||||
"Online cancellations must be submitted by the 25th of the desired cancellation month. You will receive a confirmation email once your request is accepted.",
|
||||
},
|
||||
{
|
||||
title: "Equipment Return",
|
||||
content:
|
||||
"Internet equipment (ONU, router) must be returned upon cancellation. Our team will provide return instructions after processing your request.",
|
||||
},
|
||||
{
|
||||
title: "Final Billing",
|
||||
content:
|
||||
"You will be billed through the end of your cancellation month. Any outstanding balance will be processed according to your billing cycle.",
|
||||
},
|
||||
],
|
||||
sim: [
|
||||
{
|
||||
title: "Cancellation Deadline",
|
||||
content:
|
||||
"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.",
|
||||
},
|
||||
{
|
||||
title: "Minimum Contract Term",
|
||||
content:
|
||||
"The SONIXNET SIM has a 3-month minimum contract term (sign-up month not included). Early cancellation will incur charges for remaining months.",
|
||||
},
|
||||
{
|
||||
title: "Option Services",
|
||||
content:
|
||||
"Cancelling the base plan will also cancel all associated options (Voice Mail, Call Waiting). To cancel options only, please contact support.",
|
||||
},
|
||||
{
|
||||
title: "MNP Transfer",
|
||||
content:
|
||||
"Your phone number will be lost upon cancellation. To keep the number via MNP transfer (¥1,000+tax), contact Assist Solutions before cancelling.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const CANCELLATION_STEP3_NOTICES: Record<ServiceType, CancellationNotice[]> = {
|
||||
internet: [],
|
||||
sim: [
|
||||
{
|
||||
title: "Voice-enabled SIM Notice",
|
||||
content:
|
||||
"Calling charges are post-paid. Final month charges will be billed during the first week of the second month after cancellation.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { Controller, Get, Post, Body, Param, Request } from "@nestjs/common";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import {
|
||||
cancellationPreviewSchema,
|
||||
subscriptionIdParamSchema,
|
||||
internetCancelRequestSchema,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
CancellationPreview,
|
||||
InternetCancelRequest,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { CancellationService } from "./cancellation.service.js";
|
||||
|
||||
// DTOs
|
||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||
class CancelRequestDto extends createZodDto(internetCancelRequestSchema) {}
|
||||
class CancellationPreviewDto extends createZodDto(cancellationPreviewSchema) {}
|
||||
|
||||
/**
|
||||
* Unified Cancellation Controller
|
||||
*
|
||||
* Provides generic endpoints for cancelling any service type (SIM / Internet).
|
||||
* Service type is auto-detected from the subscription product name.
|
||||
*/
|
||||
@Controller("subscriptions/:id/cancel")
|
||||
export class CancellationController {
|
||||
constructor(private readonly cancellationService: CancellationService) {}
|
||||
|
||||
/**
|
||||
* GET /subscriptions/:id/cancel/preview
|
||||
*
|
||||
* Returns unified cancellation preview for any service type.
|
||||
* Includes:
|
||||
* - Service info (dynamic fields based on type)
|
||||
* - Terms and warnings
|
||||
* - Cancellation status from Salesforce Opportunity (if linked)
|
||||
*/
|
||||
@Get("preview")
|
||||
@ZodResponse({ description: "Cancellation preview", type: CancellationPreviewDto })
|
||||
async getPreview(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SubscriptionIdParamDto
|
||||
): Promise<CancellationPreview> {
|
||||
return this.cancellationService.getPreview(req.user.id, params.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /subscriptions/:id/cancel
|
||||
*
|
||||
* Submit cancellation request for any service type.
|
||||
* Routes to appropriate handler (Freebit for SIM, Case for Internet).
|
||||
*/
|
||||
@Post()
|
||||
async submit(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Body() body: CancelRequestDto
|
||||
): Promise<void> {
|
||||
return this.cancellationService.submit(req.user.id, params.id, body as InternetCancelRequest);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Logger, Module } from "@nestjs/common";
|
||||
import { CancellationController } from "./cancellation.controller.js";
|
||||
import { CancellationService } from "./cancellation.service.js";
|
||||
import { SubscriptionsService } from "../subscriptions.service.js";
|
||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { InternetManagementModule } from "../internet-management/internet-management.module.js";
|
||||
import { SimManagementModule } from "../sim-management/sim-management.module.js";
|
||||
|
||||
/**
|
||||
* Unified Cancellation Module
|
||||
*
|
||||
* Provides a single endpoint for cancelling any service type.
|
||||
* Delegates to InternetCancellationService or SimCancellationService based on
|
||||
* service type detected from subscription product name.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
WhmcsModule,
|
||||
SalesforceModule,
|
||||
MappingsModule,
|
||||
InternetManagementModule,
|
||||
SimManagementModule,
|
||||
],
|
||||
controllers: [CancellationController],
|
||||
providers: [CancellationService, SubscriptionsService, Logger],
|
||||
exports: [CancellationService],
|
||||
})
|
||||
export class CancellationModule {}
|
||||
@ -0,0 +1,253 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import type {
|
||||
CancellationPreview,
|
||||
CancellationStatus,
|
||||
ServiceType,
|
||||
InternetCancelRequest,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { SimCancelFullRequest } from "@customer-portal/domain/sim";
|
||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||
import { SubscriptionsService } from "../subscriptions.service.js";
|
||||
import { InternetCancellationService } from "../internet-management/services/internet-cancellation.service.js";
|
||||
import { SimCancellationService } from "../sim-management/services/sim-cancellation.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { CANCELLATION_STEP3_NOTICES, CANCELLATION_TERMS } from "./cancellation-terms.config.js";
|
||||
|
||||
// Valid stages for portal display
|
||||
const VALID_PORTAL_STAGES: ReadonlySet<string> = new Set([
|
||||
OPPORTUNITY_STAGE.ACTIVE,
|
||||
OPPORTUNITY_STAGE.CANCELLING,
|
||||
OPPORTUNITY_STAGE.CANCELLED,
|
||||
]);
|
||||
|
||||
type PortalStage = "Active" | "△Cancelling" | "〇Cancelled";
|
||||
|
||||
function isValidPortalStage(stage: string): stage is PortalStage {
|
||||
return VALID_PORTAL_STAGES.has(stage);
|
||||
}
|
||||
|
||||
function detectServiceType(productName: string): ServiceType {
|
||||
const lower = productName.toLowerCase();
|
||||
|
||||
// SIM heuristics
|
||||
if (lower.includes("sim")) return "sim";
|
||||
|
||||
// Internet heuristics (match existing patterns)
|
||||
const isInternet =
|
||||
lower.includes("internet") ||
|
||||
lower.includes("sonixnet") ||
|
||||
(lower.includes("ntt") && lower.includes("fiber"));
|
||||
|
||||
if (isInternet) return "internet";
|
||||
|
||||
throw new BadRequestException("This endpoint is only for SIM or Internet subscriptions");
|
||||
}
|
||||
|
||||
function getOpportunityIdFromCustomFields(
|
||||
customFields?: Record<string, string>
|
||||
): string | undefined {
|
||||
if (!customFields) return undefined;
|
||||
|
||||
// Prefer exact key (as configured in WHMCS)
|
||||
const direct =
|
||||
customFields["OpportunityId"] ??
|
||||
customFields["OpportunityID"] ??
|
||||
customFields["opportunityId"] ??
|
||||
customFields["opportunityID"];
|
||||
if (direct && direct.trim().length > 0) return direct.trim();
|
||||
|
||||
// Fallback: case-insensitive scan for something like "Opportunity Id"
|
||||
const entry = Object.entries(customFields).find(([key, value]) => {
|
||||
if (!value) return false;
|
||||
const k = key.toLowerCase();
|
||||
return k.includes("opportunity") && k.replace(/\s+/g, "").includes("id");
|
||||
});
|
||||
return entry?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CancellationService {
|
||||
private readonly logger = new Logger(CancellationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly internetCancellation: InternetCancellationService,
|
||||
private readonly simCancellation: SimCancellationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get unified cancellation preview for any service type.
|
||||
*
|
||||
* This method:
|
||||
* 1. Fetches the subscription from WHMCS (includes custom fields)
|
||||
* 2. Detects service type from product name
|
||||
* 3. Queries Opportunity status by ID (fast, no SOQL) only when WHMCS is still Active
|
||||
* 4. Delegates to service-specific preview logic
|
||||
*/
|
||||
async getPreview(userId: string, subscriptionId: number): Promise<CancellationPreview> {
|
||||
// 1) Read subscription from WHMCS (includes custom fields)
|
||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||
userId,
|
||||
subscriptionId
|
||||
);
|
||||
const serviceType = detectServiceType(subscription.productName);
|
||||
const opportunityId = getOpportunityIdFromCustomFields(subscription.customFields);
|
||||
|
||||
// 2) Query Opportunity status ONLY when WHMCS is Active (not already cancelled)
|
||||
const shouldQueryOpp = subscription.status === "Active" && Boolean(opportunityId);
|
||||
|
||||
// 3) Service-specific handling
|
||||
if (serviceType === "internet") {
|
||||
return this.buildInternetPreview(userId, subscriptionId, opportunityId, shouldQueryOpp);
|
||||
}
|
||||
|
||||
return this.buildSimPreview(userId, subscriptionId, opportunityId, shouldQueryOpp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit cancellation request (routes to appropriate handler)
|
||||
*/
|
||||
async submit(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: InternetCancelRequest
|
||||
): Promise<void> {
|
||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||
userId,
|
||||
subscriptionId
|
||||
);
|
||||
const serviceType = detectServiceType(subscription.productName);
|
||||
|
||||
if (serviceType === "internet") {
|
||||
await this.internetCancellation.submitCancellation(userId, subscriptionId, request);
|
||||
return;
|
||||
}
|
||||
|
||||
// SIM full flow expects the same shape (structural typing is compatible)
|
||||
await this.simCancellation.cancelSimFull(
|
||||
userId,
|
||||
subscriptionId,
|
||||
request as SimCancelFullRequest
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helpers
|
||||
// =========================================================================
|
||||
|
||||
private async buildInternetPreview(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
opportunityId: string | undefined,
|
||||
shouldQueryOpp: boolean
|
||||
): Promise<CancellationPreview> {
|
||||
const preview = await this.internetCancellation.getCancellationPreview(userId, subscriptionId);
|
||||
|
||||
// Query Opportunity status by ID (fast direct lookup)
|
||||
const cancellationStatus =
|
||||
shouldQueryOpp && opportunityId
|
||||
? await this.getInternetCancellationStatus(opportunityId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
serviceType: "internet",
|
||||
serviceName: preview.productName,
|
||||
opportunityId,
|
||||
serviceInfo: [
|
||||
{ label: "Service", value: preview.productName },
|
||||
{ label: "Monthly", value: `¥${preview.billingAmount.toLocaleString()}` },
|
||||
{ label: "Next Due", value: preview.nextDueDate || "—" },
|
||||
],
|
||||
terms: CANCELLATION_TERMS.internet,
|
||||
warnings: [],
|
||||
step3Notices: CANCELLATION_STEP3_NOTICES.internet,
|
||||
cancellationStatus,
|
||||
availableMonths: preview.availableMonths,
|
||||
customerEmail: preview.customerEmail,
|
||||
customerName: preview.customerName,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildSimPreview(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
opportunityId: string | undefined,
|
||||
shouldQueryOpp: boolean
|
||||
): Promise<CancellationPreview> {
|
||||
const preview = await this.simCancellation.getCancellationPreview(userId, subscriptionId);
|
||||
|
||||
// Query Opportunity status by ID (fast direct lookup)
|
||||
const cancellationStatus =
|
||||
shouldQueryOpp && opportunityId ? await this.getSimCancellationStatus(opportunityId) : null;
|
||||
|
||||
const warnings =
|
||||
preview.isWithinMinimumTerm && preview.minimumContractEndDate
|
||||
? [
|
||||
{
|
||||
title: "Minimum Contract Term Warning",
|
||||
content: `Your subscription is within the minimum contract period (ends ${preview.minimumContractEndDate}). Early cancellation may incur additional charges.`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
serviceType: "sim",
|
||||
serviceName: `SIM: ${preview.simNumber}`,
|
||||
opportunityId,
|
||||
serviceInfo: [
|
||||
{ label: "SIM Number", value: preview.simNumber },
|
||||
{ label: "Serial #", value: preview.serialNumber || "—", mono: true },
|
||||
{ label: "Start Date", value: preview.startDate || "—" },
|
||||
],
|
||||
terms: CANCELLATION_TERMS.sim,
|
||||
warnings,
|
||||
step3Notices: CANCELLATION_STEP3_NOTICES.sim,
|
||||
cancellationStatus,
|
||||
availableMonths: preview.availableMonths,
|
||||
customerEmail: preview.customerEmail,
|
||||
customerName: preview.customerName,
|
||||
isWithinMinimumTerm: preview.isWithinMinimumTerm,
|
||||
minimumContractEndDate: preview.minimumContractEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
private async getInternetCancellationStatus(opportunityId: string): Promise<CancellationStatus> {
|
||||
const oppStatus =
|
||||
await this.opportunityService.getInternetCancellationStatusByOpportunityId(opportunityId);
|
||||
if (!oppStatus) return null;
|
||||
|
||||
// Only return valid portal stages
|
||||
if (!isValidPortalStage(oppStatus.stage)) {
|
||||
this.logger.debug(
|
||||
`Opportunity ${opportunityId} stage "${oppStatus.stage}" not shown in portal`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stage: oppStatus.stage,
|
||||
scheduledEndDate: oppStatus.scheduledEndDate,
|
||||
rentalReturnStatus: oppStatus.rentalReturnStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSimCancellationStatus(opportunityId: string): Promise<CancellationStatus> {
|
||||
const oppStatus =
|
||||
await this.opportunityService.getSimCancellationStatusByOpportunityId(opportunityId);
|
||||
if (!oppStatus) return null;
|
||||
|
||||
// Only return valid portal stages
|
||||
if (!isValidPortalStage(oppStatus.stage)) {
|
||||
this.logger.debug(
|
||||
`Opportunity ${opportunityId} stage "${oppStatus.stage}" not shown in portal`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stage: oppStatus.stage,
|
||||
scheduledEndDate: oppStatus.scheduledEndDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
2
apps/bff/src/modules/subscriptions/cancellation/index.ts
Normal file
2
apps/bff/src/modules/subscriptions/cancellation/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { CancellationModule } from "./cancellation.module.js";
|
||||
export { CancellationService } from "./cancellation.service.js";
|
||||
@ -13,6 +13,7 @@ import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||
import { SimManagementModule } from "./sim-management/sim-management.module.js";
|
||||
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
||||
import { CallHistoryModule } from "./call-history/call-history.module.js";
|
||||
import { CancellationModule } from "./cancellation/cancellation.module.js";
|
||||
// Import SimController to register it directly in this module before SubscriptionsController
|
||||
import { SimController } from "./sim-management/sim.controller.js";
|
||||
|
||||
@ -27,6 +28,7 @@ import { SimController } from "./sim-management/sim.controller.js";
|
||||
SimManagementModule,
|
||||
InternetManagementModule,
|
||||
CallHistoryModule,
|
||||
CancellationModule,
|
||||
],
|
||||
// Register SimController BEFORE SubscriptionsController to ensure more specific routes
|
||||
// (like :id/sim) are matched before less specific routes (like :id)
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import CancelSubscriptionContainer from "@/features/subscriptions/views/CancelSubscription";
|
||||
|
||||
export default function CancelSubscriptionPage() {
|
||||
return <CancelSubscriptionContainer />;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import InternetCancelContainer from "@/features/subscriptions/views/InternetCancel";
|
||||
|
||||
export default function AccountInternetCancelPage() {
|
||||
return <InternetCancelContainer />;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
|
||||
|
||||
export default function AccountSimCancelPage() {
|
||||
return <SimCancelContainer />;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { apiClient, getDataOrThrow } from "@/core/api";
|
||||
import type {
|
||||
CancellationPreview,
|
||||
InternetCancelRequest,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import { cancellationPreviewSchema } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
/**
|
||||
* Unified Cancellation API Service
|
||||
*
|
||||
* Single API for cancelling any service type (SIM or Internet).
|
||||
* Service type is auto-detected by the backend from the subscription product name.
|
||||
*/
|
||||
export const cancellationService = {
|
||||
/**
|
||||
* Get unified cancellation preview
|
||||
*
|
||||
* Returns service info, terms, warnings, and cancellation status (from Opportunity).
|
||||
* Service type is detected from subscription product name.
|
||||
*/
|
||||
async getPreview(subscriptionId: string): Promise<CancellationPreview> {
|
||||
const response = await apiClient.GET<CancellationPreview>(
|
||||
"/api/subscriptions/{id}/cancel/preview",
|
||||
{
|
||||
params: { path: { id: subscriptionId } },
|
||||
}
|
||||
);
|
||||
|
||||
const payload = getDataOrThrow(response, "Failed to load cancellation information");
|
||||
return cancellationPreviewSchema.parse(payload);
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit cancellation request
|
||||
*
|
||||
* Routes to appropriate handler based on service type (Freebit for SIM, Case for Internet).
|
||||
*/
|
||||
async submit(subscriptionId: string, request: InternetCancelRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{id}/cancel", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: request,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export { internetActionsService } from "./internet-actions.api";
|
||||
export { simActionsService } from "./sim-actions.api";
|
||||
export { cancellationService } from "./cancellation.api";
|
||||
|
||||
@ -236,7 +236,7 @@ export function SimActions({
|
||||
onClick={() => {
|
||||
setActiveInfo("cancel");
|
||||
try {
|
||||
router.push(`/account/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
router.push(`/account/subscriptions/${subscriptionId}/cancel`);
|
||||
} catch {
|
||||
// Fallback to inline confirmation modal if navigation is unavailable
|
||||
setShowCancelConfirm(true);
|
||||
|
||||
@ -60,7 +60,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
router.push(`/account/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
const navigateToReissue = () =>
|
||||
router.push(`/account/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
const navigateToCancel = () => router.push(`/account/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
const navigateToCancel = () => router.push(`/account/subscriptions/${subscriptionId}/cancel`);
|
||||
const navigateToCallHistory = () =>
|
||||
router.push(`/account/subscriptions/${subscriptionId}/sim/call-history`);
|
||||
|
||||
|
||||
@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cancellationService } from "@/features/subscriptions/api";
|
||||
import type { CancellationPreview } from "@customer-portal/domain/subscriptions";
|
||||
import { GlobeAltIcon, DevicePhoneMobileIcon, ClockIcon } from "@heroicons/react/24/outline";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { Button } from "@/components/atoms";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CancellationFlow,
|
||||
Notice,
|
||||
InfoNotice,
|
||||
ServiceInfoGrid,
|
||||
ServiceInfoItem,
|
||||
CancellationSummary,
|
||||
MinimumContractWarning,
|
||||
} from "@/features/subscriptions/components/CancellationFlow";
|
||||
|
||||
// ============================================================================
|
||||
// Pending Cancellation View (when Opportunity is already in △Cancelling)
|
||||
// ============================================================================
|
||||
|
||||
function CancellationPendingView({
|
||||
subscriptionId,
|
||||
preview,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
preview: CancellationPreview;
|
||||
}) {
|
||||
const icon = preview.serviceType === "internet" ? <GlobeAltIcon /> : <DevicePhoneMobileIcon />;
|
||||
const title =
|
||||
preview.serviceType === "internet"
|
||||
? "Internet Cancellation Pending"
|
||||
: "SIM Cancellation Pending";
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={preview.serviceName}
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{ label: preview.serviceName, href: `/account/subscriptions/${subscriptionId}` },
|
||||
{ label: "Cancellation Status" },
|
||||
]}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-card border border-border rounded-xl p-6 sm:p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 bg-warning-soft rounded-full flex items-center justify-center">
|
||||
<ClockIcon className="w-6 h-6 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Cancellation In Progress</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your cancellation request is being processed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Service:</span>
|
||||
<div className="font-medium text-foreground">{preview.serviceName}</div>
|
||||
</div>
|
||||
{preview.cancellationStatus?.scheduledEndDate && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Scheduled End:</span>
|
||||
<div className="font-medium text-foreground">
|
||||
{new Date(preview.cancellationStatus.scheduledEndDate).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "long", year: "numeric" }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview.serviceType === "internet" &&
|
||||
preview.cancellationStatus?.rentalReturnStatus && (
|
||||
<div className="p-4 bg-info-soft/50 border-l-4 border-info rounded-r-lg">
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
Equipment Return Status
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{preview.cancellationStatus.rentalReturnStatus}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will receive an email confirmation when the cancellation is complete. If you have
|
||||
questions, please contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<Link href={`/account/subscriptions/${subscriptionId}`}>
|
||||
<Button variant="outline">Back to Subscription</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export function CancelSubscriptionContainer() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preview, setPreview] = useState<CancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const data = await cancellationService.getPreview(subscriptionId);
|
||||
setPreview(data);
|
||||
} catch (e: unknown) {
|
||||
setError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
: "Failed to load cancellation information"
|
||||
: "Unable to load cancellation information right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
cancellationMonth: string;
|
||||
confirmRead: boolean;
|
||||
confirmCancel: boolean;
|
||||
comments?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
const monthInfo = preview?.availableMonths.find(m => m.value === data.cancellationMonth);
|
||||
setSelectedMonthLabel(monthInfo?.label || data.cancellationMonth);
|
||||
|
||||
try {
|
||||
await cancellationService.submit(subscriptionId, {
|
||||
cancellationMonth: data.cancellationMonth,
|
||||
confirmRead: data.confirmRead,
|
||||
confirmCancel: data.confirmCancel,
|
||||
comments: data.comments,
|
||||
});
|
||||
setSuccessMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(() => router.push(`/account/subscriptions/${subscriptionId}`), 2000);
|
||||
} catch (e: unknown) {
|
||||
setFormError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
: "Failed to submit cancellation"
|
||||
: "Unable to submit your cancellation right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading or error state
|
||||
if (loading || error) {
|
||||
const icon = <GlobeAltIcon />;
|
||||
return (
|
||||
<PageLayout
|
||||
icon={icon}
|
||||
title="Cancel Subscription"
|
||||
description="Loading cancellation information..."
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{ label: "Cancel" },
|
||||
]}
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<></>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If already cancelling, show pending status
|
||||
if (preview.cancellationStatus?.stage === "△Cancelling") {
|
||||
return <CancellationPendingView subscriptionId={subscriptionId} preview={preview} />;
|
||||
}
|
||||
|
||||
// Build dynamic content based on service type
|
||||
const icon = preview.serviceType === "internet" ? <GlobeAltIcon /> : <DevicePhoneMobileIcon />;
|
||||
const title =
|
||||
preview.serviceType === "internet" ? "Cancel Internet Service" : "Cancel SIM Service";
|
||||
const confirmMessage =
|
||||
preview.serviceType === "internet"
|
||||
? "Are you sure you want to cancel your Internet service? This will take effect at the end of {month}."
|
||||
: "Are you sure you want to cancel your SIM subscription? This will take effect at the end of {month}.";
|
||||
|
||||
return (
|
||||
<CancellationFlow
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={preview.serviceName}
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{ label: preview.serviceName, href: `/account/subscriptions/${subscriptionId}` },
|
||||
{ label: "Cancel" },
|
||||
]}
|
||||
backHref={`/account/subscriptions/${subscriptionId}`}
|
||||
backLabel="Back to Subscription"
|
||||
availableMonths={preview.availableMonths}
|
||||
customerEmail={preview.customerEmail}
|
||||
loading={false}
|
||||
error={null}
|
||||
formError={formError}
|
||||
successMessage={successMessage}
|
||||
submitting={submitting}
|
||||
confirmMessage={confirmMessage}
|
||||
onSubmit={handleSubmit}
|
||||
warningBanner={
|
||||
preview.isWithinMinimumTerm && preview.minimumContractEndDate ? (
|
||||
<MinimumContractWarning endDate={preview.minimumContractEndDate} />
|
||||
) : undefined
|
||||
}
|
||||
serviceInfo={
|
||||
<ServiceInfoGrid>
|
||||
{preview.serviceInfo.map((info, idx) => (
|
||||
<ServiceInfoItem key={idx} label={info.label} value={info.value} mono={info.mono} />
|
||||
))}
|
||||
</ServiceInfoGrid>
|
||||
}
|
||||
termsContent={
|
||||
<div className="space-y-3">
|
||||
{preview.terms.map((term, idx) => (
|
||||
<Notice key={idx} title={term.title}>
|
||||
{term.content}
|
||||
</Notice>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
summaryContent={
|
||||
<>
|
||||
<CancellationSummary
|
||||
items={preview.serviceInfo.map(info => ({ label: info.label, value: info.value }))}
|
||||
selectedMonth={selectedMonthLabel || "the selected month"}
|
||||
/>
|
||||
{/* Step 3 extra notices (e.g., Voice SIM billing) */}
|
||||
{preview.step3Notices && preview.step3Notices.length > 0 && (
|
||||
<div className="space-y-3 mt-4">
|
||||
{preview.step3Notices.map((notice, idx) => (
|
||||
<InfoNotice key={idx} title={notice.title}>
|
||||
{notice.content}
|
||||
</InfoNotice>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CancelSubscriptionContainer;
|
||||
@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { internetActionsService } from "@/features/subscriptions/api/internet-actions.api";
|
||||
import type { InternetCancellationPreview } from "@customer-portal/domain/subscriptions";
|
||||
import { GlobeAltIcon } from "@heroicons/react/24/outline";
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preview, setPreview] = useState<InternetCancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const data = await internetActionsService.getCancellationPreview(subscriptionId);
|
||||
setPreview(data);
|
||||
} catch (e: unknown) {
|
||||
setError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
: "Failed to load cancellation information"
|
||||
: "Unable to load cancellation information right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const formatCurrency = (amount: number) => `¥${amount.toLocaleString()}`;
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
cancellationMonth: string;
|
||||
confirmRead: boolean;
|
||||
confirmCancel: boolean;
|
||||
comments?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
// 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: data.cancellationMonth,
|
||||
confirmRead: data.confirmRead,
|
||||
confirmCancel: data.confirmCancel,
|
||||
comments: data.comments,
|
||||
});
|
||||
setSuccessMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(() => router.push(`/account/subscriptions/${subscriptionId}`), 2000);
|
||||
} catch (e: unknown) {
|
||||
setFormError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
: "Failed to submit cancellation"
|
||||
: "Unable to submit your cancellation right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!preview && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CancellationFlow
|
||||
icon={<GlobeAltIcon />}
|
||||
title="Cancel Internet Service"
|
||||
description={preview?.productName || "Cancel your Internet subscription"}
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{
|
||||
label: preview?.productName || "Internet",
|
||||
href: `/account/subscriptions/${subscriptionId}`,
|
||||
},
|
||||
{ label: "Cancel" },
|
||||
]}
|
||||
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>
|
||||
|
||||
<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>
|
||||
}
|
||||
summaryContent={
|
||||
<CancellationSummary
|
||||
items={[{ label: "Service", value: preview?.productName || "—" }]}
|
||||
selectedMonth={selectedMonthLabel || "the selected month"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default InternetCancelContainer;
|
||||
@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
|
||||
import type { SimCancellationPreview } from "@customer-portal/domain/sim";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preview, setPreview] = useState<SimCancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedMonthLabel, setSelectedMonthLabel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const data = await simActionsService.getCancellationPreview(subscriptionId);
|
||||
setPreview(data);
|
||||
} catch (e: unknown) {
|
||||
setError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
: "Failed to load cancellation information"
|
||||
: "Unable to load cancellation information right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
cancellationMonth: string;
|
||||
confirmRead: boolean;
|
||||
confirmCancel: boolean;
|
||||
comments?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
// 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: data.cancellationMonth,
|
||||
confirmRead: data.confirmRead,
|
||||
confirmCancel: data.confirmCancel,
|
||||
comments: data.comments,
|
||||
});
|
||||
setSuccessMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(
|
||||
() => router.push(`/account/subscriptions/${subscriptionId}#sim-management`),
|
||||
2000
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
setFormError(
|
||||
process.env.NODE_ENV === "development"
|
||||
? e instanceof Error
|
||||
? e.message
|
||||
: "Failed to submit cancellation"
|
||||
: "Unable to submit your cancellation right now. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!preview && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CancellationFlow
|
||||
icon={<DevicePhoneMobileIcon />}
|
||||
title="Cancel SIM Service"
|
||||
description={
|
||||
preview?.simNumber ? `SIM: ${preview.simNumber}` : "Cancel your SIM subscription"
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||
{
|
||||
label: "SIM Management",
|
||||
href: `/account/subscriptions/${subscriptionId}#sim-management`,
|
||||
},
|
||||
{ label: "Cancel SIM" },
|
||||
]}
|
||||
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>
|
||||
|
||||
<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>
|
||||
}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimCancelContainer;
|
||||
@ -220,7 +220,7 @@ export function SubscriptionDetailContainer() {
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/account/subscriptions/${subscriptionId}/internet/cancel`}
|
||||
href={`/account/subscriptions/${subscriptionId}/cancel`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-danger-foreground bg-danger hover:bg-danger/90 rounded-lg transition-colors"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
|
||||
@ -19,4 +19,9 @@ export {
|
||||
internetCancellationMonthSchema,
|
||||
internetCancellationPreviewSchema,
|
||||
internetCancelRequestSchema,
|
||||
// Unified cancellation
|
||||
serviceTypeSchema,
|
||||
cancellationNoticeSchema,
|
||||
cancellationStatusSchema,
|
||||
cancellationPreviewSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
@ -174,3 +174,82 @@ export const internetCancelRequestSchema = z.object({
|
||||
export type InternetCancellationMonth = z.infer<typeof internetCancellationMonthSchema>;
|
||||
export type InternetCancellationPreview = z.infer<typeof internetCancellationPreviewSchema>;
|
||||
export type InternetCancelRequest = z.infer<typeof internetCancelRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Unified Cancellation Preview (SIM + Internet)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Service type for cancellation flows
|
||||
*/
|
||||
export const serviceTypeSchema = z.enum(["sim", "internet"]);
|
||||
export type ServiceType = z.infer<typeof serviceTypeSchema>;
|
||||
|
||||
/**
|
||||
* Structured notice/term content for cancellation pages
|
||||
*/
|
||||
export const cancellationNoticeSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
export type CancellationNotice = z.infer<typeof cancellationNoticeSchema>;
|
||||
|
||||
/**
|
||||
* Cancellation status derived from Salesforce Opportunity (if available)
|
||||
*
|
||||
* Notes:
|
||||
* - We only need a small subset for portal display.
|
||||
* - This is nullable because some services may not have a linked Opportunity.
|
||||
*/
|
||||
export const cancellationStatusSchema = z
|
||||
.object({
|
||||
stage: z.enum(["Active", "△Cancelling", "〇Cancelled"]),
|
||||
scheduledEndDate: z.string().optional(),
|
||||
// Internet only
|
||||
rentalReturnStatus: z.string().optional(),
|
||||
})
|
||||
.nullable();
|
||||
export type CancellationStatus = z.infer<typeof cancellationStatusSchema>;
|
||||
|
||||
/**
|
||||
* Unified cancellation preview response used by the generic cancellation page.
|
||||
*
|
||||
* Includes:
|
||||
* - Service type + display fields
|
||||
* - Terms and notices (service-type specific)
|
||||
* - Cancellation status (derived from Opportunity when WHMCS isn't already cancelled)
|
||||
*/
|
||||
export const cancellationPreviewSchema = z.object({
|
||||
serviceType: serviceTypeSchema,
|
||||
serviceName: z.string().min(1),
|
||||
|
||||
/**
|
||||
* Salesforce Opportunity ID read from WHMCS service custom fields (already stored in WHMCS).
|
||||
* Optional because not all services are guaranteed to have it.
|
||||
*/
|
||||
opportunityId: z.string().min(15).max(18).optional(),
|
||||
|
||||
serviceInfo: z.array(
|
||||
z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.string().min(1),
|
||||
mono: z.boolean().optional(),
|
||||
})
|
||||
),
|
||||
|
||||
terms: z.array(cancellationNoticeSchema),
|
||||
warnings: z.array(cancellationNoticeSchema).default([]),
|
||||
step3Notices: z.array(cancellationNoticeSchema).default([]),
|
||||
|
||||
cancellationStatus: cancellationStatusSchema,
|
||||
|
||||
availableMonths: z.array(internetCancellationMonthSchema),
|
||||
customerEmail: z.string(),
|
||||
customerName: z.string().min(1),
|
||||
|
||||
// SIM-specific (optional)
|
||||
isWithinMinimumTerm: z.boolean().optional(),
|
||||
minimumContractEndDate: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CancellationPreview = z.infer<typeof cancellationPreviewSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user