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. +

+
+ +
+ + + +
+
+
+
+ ); +} + +// ============================================================================ +// 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;