From b19c2139315b219f5661a6e7d5082d76ce71f860 Mon Sep 17 00:00:00 2001
From: barsa
Date: Mon, 5 Jan 2026 17:06:25 +0900
Subject: [PATCH] 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.
---
.../salesforce-opportunity.service.ts | 94 ++++++
.../cancellation/cancellation-terms.config.ts | 54 ++++
.../cancellation/cancellation.controller.ts | 62 ++++
.../cancellation/cancellation.module.ts | 30 ++
.../cancellation/cancellation.service.ts | 253 ++++++++++++++++
.../subscriptions/cancellation/index.ts | 2 +
.../subscriptions/subscriptions.module.ts | 2 +
.../subscriptions/[id]/cancel/page.tsx | 5 +
.../[id]/internet/cancel/page.tsx | 5 -
.../subscriptions/[id]/sim/cancel/page.tsx | 5 -
.../subscriptions/api/cancellation.api.ts | 44 +++
.../src/features/subscriptions/api/index.ts | 1 +
.../components/sim/SimActions.tsx | 2 +-
.../components/sim/SimManagementSection.tsx | 2 +-
.../views/CancelSubscription.tsx | 286 ++++++++++++++++++
.../subscriptions/views/InternetCancel.tsx | 152 ----------
.../subscriptions/views/SimCancel.tsx | 171 -----------
.../views/SubscriptionDetail.tsx | 2 +-
packages/domain/subscriptions/index.ts | 5 +
packages/domain/subscriptions/schema.ts | 79 +++++
20 files changed, 920 insertions(+), 336 deletions(-)
create mode 100644 apps/bff/src/modules/subscriptions/cancellation/cancellation-terms.config.ts
create mode 100644 apps/bff/src/modules/subscriptions/cancellation/cancellation.controller.ts
create mode 100644 apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts
create mode 100644 apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts
create mode 100644 apps/bff/src/modules/subscriptions/cancellation/index.ts
create mode 100644 apps/portal/src/app/account/subscriptions/[id]/cancel/page.tsx
delete mode 100644 apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx
delete mode 100644 apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx
create mode 100644 apps/portal/src/features/subscriptions/api/cancellation.api.ts
create mode 100644 apps/portal/src/features/subscriptions/views/CancelSubscription.tsx
delete mode 100644 apps/portal/src/features/subscriptions/views/InternetCancel.tsx
delete mode 100644 apps/portal/src/features/subscriptions/views/SimCancel.tsx
diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts
index 542da81f..1c69b011 100644
--- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts
+++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts
@@ -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;
+
+ 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;
+
+ 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
*/
diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation-terms.config.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation-terms.config.ts
new file mode 100644
index 00000000..8484c42a
--- /dev/null
+++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation-terms.config.ts
@@ -0,0 +1,54 @@
+import type { ServiceType, CancellationNotice } from "@customer-portal/domain/subscriptions";
+
+export const CANCELLATION_TERMS: Record = {
+ 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 = {
+ 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.",
+ },
+ ],
+};
diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.controller.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.controller.ts
new file mode 100644
index 00000000..2b52445b
--- /dev/null
+++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.controller.ts
@@ -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 {
+ 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 {
+ return this.cancellationService.submit(req.user.id, params.id, body as InternetCancelRequest);
+ }
+}
diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts
new file mode 100644
index 00000000..2e4540d0
--- /dev/null
+++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts
@@ -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 {}
diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts
new file mode 100644
index 00000000..431371a2
--- /dev/null
+++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts
@@ -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 = 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 | 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 {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ };
+ }
+}
diff --git a/apps/bff/src/modules/subscriptions/cancellation/index.ts b/apps/bff/src/modules/subscriptions/cancellation/index.ts
new file mode 100644
index 00000000..848eb1a3
--- /dev/null
+++ b/apps/bff/src/modules/subscriptions/cancellation/index.ts
@@ -0,0 +1,2 @@
+export { CancellationModule } from "./cancellation.module.js";
+export { CancellationService } from "./cancellation.service.js";
diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts
index ea770c7a..88665d81 100644
--- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts
+++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts
@@ -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)
diff --git a/apps/portal/src/app/account/subscriptions/[id]/cancel/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/cancel/page.tsx
new file mode 100644
index 00000000..56048241
--- /dev/null
+++ b/apps/portal/src/app/account/subscriptions/[id]/cancel/page.tsx
@@ -0,0 +1,5 @@
+import CancelSubscriptionContainer from "@/features/subscriptions/views/CancelSubscription";
+
+export default function CancelSubscriptionPage() {
+ return ;
+}
diff --git a/apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx
deleted file mode 100644
index e1684c84..00000000
--- a/apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import InternetCancelContainer from "@/features/subscriptions/views/InternetCancel";
-
-export default function AccountInternetCancelPage() {
- return ;
-}
diff --git a/apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx
deleted file mode 100644
index f9aaf9a4..00000000
--- a/apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
-
-export default function AccountSimCancelPage() {
- return ;
-}
diff --git a/apps/portal/src/features/subscriptions/api/cancellation.api.ts b/apps/portal/src/features/subscriptions/api/cancellation.api.ts
new file mode 100644
index 00000000..b9f65c56
--- /dev/null
+++ b/apps/portal/src/features/subscriptions/api/cancellation.api.ts
@@ -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 {
+ const response = await apiClient.GET(
+ "/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 {
+ await apiClient.POST("/api/subscriptions/{id}/cancel", {
+ params: { path: { id: subscriptionId } },
+ body: request,
+ });
+ },
+};
diff --git a/apps/portal/src/features/subscriptions/api/index.ts b/apps/portal/src/features/subscriptions/api/index.ts
index 9a4bb577..6fff1d76 100644
--- a/apps/portal/src/features/subscriptions/api/index.ts
+++ b/apps/portal/src/features/subscriptions/api/index.ts
@@ -1,2 +1,3 @@
export { internetActionsService } from "./internet-actions.api";
export { simActionsService } from "./sim-actions.api";
+export { cancellationService } from "./cancellation.api";
diff --git a/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx b/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx
index b4295742..4181818e 100644
--- a/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx
+++ b/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx
@@ -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);
diff --git a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx
index 70fd94e5..cafee239 100644
--- a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx
+++ b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx
@@ -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`);
diff --git a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx
new file mode 100644
index 00000000..ff29665a
--- /dev/null
+++ b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx
@@ -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" ? : ;
+ const title =
+ preview.serviceType === "internet"
+ ? "Internet Cancellation Pending"
+ : "SIM Cancellation Pending";
+
+ return (
+
+
+
+
+
+
+
+
+
Cancellation In Progress
+
+ Your cancellation request is being processed.
+
+
+
+
+
+
+
+
+
Service:
+
{preview.serviceName}
+
+ {preview.cancellationStatus?.scheduledEndDate && (
+
+
Scheduled End:
+
+ {new Date(preview.cancellationStatus.scheduledEndDate).toLocaleDateString(
+ "en-US",
+ { month: "long", year: "numeric" }
+ )}
+
+
+ )}
+
+
+
+ {preview.serviceType === "internet" &&
+ preview.cancellationStatus?.rentalReturnStatus && (
+
+
+ Equipment Return Status
+
+
+ {preview.cancellationStatus.rentalReturnStatus}
+
+
+ )}
+
+
+ You will receive an email confirmation when the cancellation is complete. If you have
+ questions, please contact our support team.
+
+
+
+
+
+ Back to Subscription
+
+
+
+
+
+ );
+}
+
+// ============================================================================
+// 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(null);
+ const [error, setError] = useState(null);
+ const [formError, setFormError] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [selectedMonthLabel, setSelectedMonthLabel] = useState("");
+
+ 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 = ;
+ return (
+
+ <>>
+
+ );
+ }
+
+ if (!preview) {
+ return null;
+ }
+
+ // If already cancelling, show pending status
+ if (preview.cancellationStatus?.stage === "△Cancelling") {
+ return ;
+ }
+
+ // Build dynamic content based on service type
+ const icon = preview.serviceType === "internet" ? : ;
+ 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 (
+
+ ) : undefined
+ }
+ serviceInfo={
+
+ {preview.serviceInfo.map((info, idx) => (
+
+ ))}
+
+ }
+ termsContent={
+
+ {preview.terms.map((term, idx) => (
+
+ {term.content}
+
+ ))}
+
+ }
+ summaryContent={
+ <>
+ ({ 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 && (
+
+ {preview.step3Notices.map((notice, idx) => (
+
+ {notice.content}
+
+ ))}
+
+ )}
+ >
+ }
+ />
+ );
+}
+
+export default CancelSubscriptionContainer;
diff --git a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx
deleted file mode 100644
index ab5fc8b1..00000000
--- a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx
+++ /dev/null
@@ -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(null);
- const [error, setError] = useState(null);
- const [formError, setFormError] = useState(null);
- const [successMessage, setSuccessMessage] = useState(null);
- const [selectedMonthLabel, setSelectedMonthLabel] = useState("");
-
- 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 (
- }
- 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={
-
-
-
-
-
- }
- termsContent={
-
-
- Online cancellations must be submitted by the 25th of the desired cancellation month.
- You will receive a confirmation email once your request is accepted.
-
-
-
- Internet equipment (ONU, router) must be returned upon cancellation. Our team will
- provide return instructions after processing your request.
-
-
-
- You will be billed through the end of your cancellation month. Any outstanding balance
- will be processed according to your billing cycle.
-
-
- }
- summaryContent={
-
- }
- />
- );
-}
-
-export default InternetCancelContainer;
diff --git a/apps/portal/src/features/subscriptions/views/SimCancel.tsx b/apps/portal/src/features/subscriptions/views/SimCancel.tsx
deleted file mode 100644
index 02cda0b3..00000000
--- a/apps/portal/src/features/subscriptions/views/SimCancel.tsx
+++ /dev/null
@@ -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(null);
- const [error, setError] = useState(null);
- const [formError, setFormError] = useState(null);
- const [successMessage, setSuccessMessage] = useState(null);
- const [selectedMonthLabel, setSelectedMonthLabel] = useState("");
-
- 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 (
- }
- 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 ? (
-
- ) : null
- }
- serviceInfo={
-
-
-
-
-
- }
- termsContent={
-
-
- 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.
-
-
-
- The SONIXNET SIM has a 3-month minimum contract term (sign-up month not included). Early
- cancellation will incur charges for remaining months.
-
-
-
- Cancelling the base plan will also cancel all associated options (Voice Mail, Call
- Waiting). To cancel options only, please contact support.
-
-
-
- Your phone number will be lost upon cancellation. To keep the number via MNP transfer
- (¥1,000+tax), contact Assist Solutions before cancelling.
-
-
- }
- summaryContent={
-
- }
- step3ExtraContent={
-
- Calling charges are post-paid. Final month charges will be billed during the first week of
- the second month after cancellation.
-
- }
- />
- );
-}
-
-export default SimCancelContainer;
diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
index de593081..0968b746 100644
--- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
+++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
@@ -220,7 +220,7 @@ export function SubscriptionDetailContainer() {
diff --git a/packages/domain/subscriptions/index.ts b/packages/domain/subscriptions/index.ts
index a8d6161f..7cabf8f5 100644
--- a/packages/domain/subscriptions/index.ts
+++ b/packages/domain/subscriptions/index.ts
@@ -19,4 +19,9 @@ export {
internetCancellationMonthSchema,
internetCancellationPreviewSchema,
internetCancelRequestSchema,
+ // Unified cancellation
+ serviceTypeSchema,
+ cancellationNoticeSchema,
+ cancellationStatusSchema,
+ cancellationPreviewSchema,
} from "./schema.js";
diff --git a/packages/domain/subscriptions/schema.ts b/packages/domain/subscriptions/schema.ts
index 8f1fa5c4..80eb610c 100644
--- a/packages/domain/subscriptions/schema.ts
+++ b/packages/domain/subscriptions/schema.ts
@@ -174,3 +174,82 @@ export const internetCancelRequestSchema = z.object({
export type InternetCancellationMonth = z.infer;
export type InternetCancellationPreview = z.infer;
export type InternetCancelRequest = z.infer;
+
+// ============================================================================
+// Unified Cancellation Preview (SIM + Internet)
+// ============================================================================
+
+/**
+ * Service type for cancellation flows
+ */
+export const serviceTypeSchema = z.enum(["sim", "internet"]);
+export type ServiceType = z.infer;
+
+/**
+ * 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;
+
+/**
+ * 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;
+
+/**
+ * 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;