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:
barsa 2026-01-05 17:06:25 +09:00
parent 922fd3dab0
commit b19c213931
20 changed files with 920 additions and 336 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { CancellationModule } from "./cancellation.module.js";
export { CancellationService } from "./cancellation.service.js";

View File

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

View File

@ -0,0 +1,5 @@
import CancelSubscriptionContainer from "@/features/subscriptions/views/CancelSubscription";
export default function CancelSubscriptionPage() {
return <CancelSubscriptionContainer />;
}

View File

@ -1,5 +0,0 @@
import InternetCancelContainer from "@/features/subscriptions/views/InternetCancel";
export default function AccountInternetCancelPage() {
return <InternetCancelContainer />;
}

View File

@ -1,5 +0,0 @@
import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
export default function AccountSimCancelPage() {
return <SimCancelContainer />;
}

View File

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

View File

@ -1,2 +1,3 @@
export { internetActionsService } from "./internet-actions.api";
export { simActionsService } from "./sim-actions.api";
export { cancellationService } from "./cancellation.api";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,4 +19,9 @@ export {
internetCancellationMonthSchema,
internetCancellationPreviewSchema,
internetCancelRequestSchema,
// Unified cancellation
serviceTypeSchema,
cancellationNoticeSchema,
cancellationStatusSchema,
cancellationPreviewSchema,
} from "./schema.js";

View File

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