From 4573b9448436bdc88c3650eaeb35ca1f895958af Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 23 Dec 2025 15:19:20 +0900 Subject: [PATCH] Enhance WHMCS Integration and Add Internet Cancellation Features - Introduced WhmcsAccountDiscoveryService to streamline client account discovery processes. - Expanded WhmcsCacheService to include caching for subscription invoices and client email mappings, improving data retrieval efficiency. - Updated WhmcsClientService to utilize caching for client ID lookups by email, enhancing performance. - Implemented new internet cancellation features in SubscriptionsController, allowing users to preview and submit cancellation requests for internet services. - Added validation schemas for internet cancellation requests, ensuring data integrity and user guidance during the cancellation process. - Refactored various components and services to integrate new cancellation functionalities, improving user experience and operational flow. --- .../whmcs/cache/whmcs-cache.service.ts | 75 ++++ .../whmcs-account-discovery.service.ts | 93 ++++ .../whmcs/services/whmcs-client.service.ts | 29 -- .../services/whmcs-subscription.service.ts | 39 +- .../src/integrations/whmcs/whmcs.module.ts | 3 + .../src/integrations/whmcs/whmcs.service.ts | 8 - .../modules/auth/application/auth.facade.ts | 13 +- .../workflows/signup-workflow.service.ts | 118 +++-- .../workflows/whmcs-link-workflow.service.ts | 22 +- .../http/guards/global-auth.guard.ts | 2 +- .../internet-management.module.ts | 13 + .../services/internet-cancellation.service.ts | 307 +++++++++++++ .../subscriptions/subscriptions.controller.ts | 44 +- .../subscriptions/subscriptions.module.ts | 10 +- .../subscriptions/subscriptions.service.ts | 19 + .../users/infra/user-profile.service.ts | 27 +- .../services/[id]/internet/cancel/page.tsx | 5 + .../account/views/ProfileContainer.tsx | 185 +++++++- .../services/internet-actions.service.ts | 52 +++ .../subscriptions/views/InternetCancel.tsx | 405 ++++++++++++++++++ .../views/SubscriptionDetail.tsx | 60 ++- .../ResidenceCardVerificationSettingsView.tsx | 103 +++-- docs/SECURITY-MONITORING.md | 220 ---------- docs/_archive/README.md | 30 ++ .../planning}/PUBLIC-CATALOG-TASKS.md | 0 .../PUBLIC-CATALOG-UNIFIED-CHECKOUT.md | 0 docs/_archive/planning/README.md | 3 + .../CLEAN-ARCHITECTURE-SUMMARY.md | 0 docs/_archive/refactoring/README.md | 3 + .../refactoring}/SUBSCRIPTIONS-REFACTOR.md | 43 +- docs/_archive/reviews/README.md | 3 + .../reviews/SHOP-CHECKOUT-REVIEW.md | 0 ...TY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md | 88 ++-- docs/portal-guides/COMPLETE-GUIDE.docx | Bin 23370 -> 0 bytes docs/portal-guides/README.md | 31 +- .../eligibility-and-verification.md | 317 +++++++------- docs/shop-ux-improvements.md | 98 ----- packages/domain/subscriptions/index.ts | 21 +- packages/domain/subscriptions/schema.ts | 44 +- 39 files changed, 1792 insertions(+), 741 deletions(-) create mode 100644 apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts create mode 100644 apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts create mode 100644 apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts create mode 100644 apps/portal/src/app/account/services/[id]/internet/cancel/page.tsx create mode 100644 apps/portal/src/features/subscriptions/services/internet-actions.service.ts create mode 100644 apps/portal/src/features/subscriptions/views/InternetCancel.tsx delete mode 100644 docs/SECURITY-MONITORING.md create mode 100644 docs/_archive/README.md rename docs/{architecture => _archive/planning}/PUBLIC-CATALOG-TASKS.md (100%) rename docs/{architecture => _archive/planning}/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md (100%) create mode 100644 docs/_archive/planning/README.md rename docs/{architecture => _archive/refactoring}/CLEAN-ARCHITECTURE-SUMMARY.md (100%) create mode 100644 docs/_archive/refactoring/README.md rename docs/{architecture => _archive/refactoring}/SUBSCRIPTIONS-REFACTOR.md (86%) create mode 100644 docs/_archive/reviews/README.md rename docs/{ => _archive}/reviews/SHOP-CHECKOUT-REVIEW.md (100%) rename docs/{ => _archive}/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md (94%) delete mode 100644 docs/portal-guides/COMPLETE-GUIDE.docx delete mode 100644 docs/shop-ux-improvements.md diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index 2f3362bc..a4d8da7d 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -42,11 +42,21 @@ export class WhmcsCacheService { ttl: 600, // 10 minutes - individual subscriptions rarely change tags: ["subscription", "services"], }, + subscriptionInvoices: { + prefix: "whmcs:subscription:invoices", + ttl: 300, // 5 minutes + tags: ["subscription", "invoices"], + }, client: { prefix: "whmcs:client", ttl: 1800, // 30 minutes - client data rarely changes tags: ["client", "user"], }, + clientEmail: { + prefix: "whmcs:client:email", + ttl: 1800, // 30 minutes + tags: ["client", "email"], + }, sso: { prefix: "whmcs:sso", ttl: 3600, // 1 hour - SSO tokens have their own expiry @@ -144,6 +154,36 @@ export class WhmcsCacheService { await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]); } + /** + * Get cached subscription invoices + */ + async getSubscriptionInvoices( + userId: string, + subscriptionId: number, + page: number, + limit: number + ): Promise { + const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); + return this.get(key, "subscriptionInvoices"); + } + + /** + * Cache subscription invoices + */ + async setSubscriptionInvoices( + userId: string, + subscriptionId: number, + page: number, + limit: number, + data: InvoiceList + ): Promise { + const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); + await this.set(key, data, "subscriptionInvoices", [ + `user:${userId}`, + `subscription:${subscriptionId}`, + ]); + } + /** * Get cached client data * Returns WhmcsClient (type inferred from domain) @@ -161,6 +201,22 @@ export class WhmcsCacheService { await this.set(key, data, "client", [`client:${clientId}`]); } + /** + * Get cached client ID by email + */ + async getClientIdByEmail(email: string): Promise { + const key = this.buildClientEmailKey(email); + return this.get(key, "clientEmail"); + } + + /** + * Cache client ID for email + */ + async setClientIdByEmail(email: string, clientId: number): Promise { + const key = this.buildClientEmailKey(email); + await this.set(key, clientId, "clientEmail"); + } + /** * Invalidate all cache for a specific user */ @@ -383,6 +439,18 @@ export class WhmcsCacheService { return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`; } + /** + * Build cache key for subscription invoices + */ + private buildSubscriptionInvoicesKey( + userId: string, + subscriptionId: number, + page: number, + limit: number + ): string { + return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`; + } + /** * Build cache key for client data */ @@ -390,6 +458,13 @@ export class WhmcsCacheService { return `${this.cacheConfigs.client.prefix}:${clientId}`; } + /** + * Build cache key for client email mapping + */ + private buildClientEmailKey(email: string): string { + return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`; + } + /** * Build cache key for payment methods */ diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts new file mode 100644 index 00000000..94355b62 --- /dev/null +++ b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js"; +import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; +import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; +import type { WhmcsClient } from "@customer-portal/domain/customer"; + +/** + * Service for discovering and verifying WHMCS accounts by email. + * Separated from WhmcsClientService to isolate "discovery" logic from "authenticated" logic. + */ +@Injectable() +export class WhmcsAccountDiscoveryService { + constructor( + private readonly connectionService: WhmcsConnectionOrchestratorService, + private readonly cacheService: WhmcsCacheService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Find a client by email address. + * This is a "discovery" operation used during signup/login flows. + * It uses a specialized cache to map Email -> Client ID. + */ + async findClientByEmail(email: string): Promise { + try { + // 1. Try to find client ID by email from cache + const cachedClientId = await this.cacheService.getClientIdByEmail(email); + if (cachedClientId) { + this.logger.debug(`Cache hit for email-to-id: ${email} -> ${cachedClientId}`); + // If we have ID, fetch the full client data (which has its own cache) + return this.getClientDetailsById(cachedClientId); + } + + // 2. If no mapping, fetch from API + // We use a try-catch here because the connection service might throw if not found + // or if the API returns a specific error for "no results" + const response = await this.connectionService.getClientDetailsByEmail(email); + + if (!response || !response.client) { + // Not found is a valid state for discovery (return null) + return null; + } + + const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); + + // 3. Cache both the data and the mapping + await Promise.all([ + this.cacheService.setClientData(client.id, client), + this.cacheService.setClientIdByEmail(email, client.id), + ]); + + this.logger.log(`Discovered client by email: ${email}`); + return client; + } catch (error) { + // Handle "Not Found" specifically + if ( + error instanceof NotFoundException || + (error instanceof Error && error.message.includes("not found")) + ) { + return null; + } + + // Log other errors but don't crash - return null to indicate lookup failed safely + this.logger.warn(`Failed to discover client by email: ${email}`, { + error: getErrorMessage(error), + }); + return null; + } + } + + /** + * Helper to get details by ID, reusing the cache logic from ClientService logic + * We duplicate this small fetch to avoid circular dependency or tight coupling with WhmcsClientService + */ + private async getClientDetailsById(clientId: number): Promise { + // Try cache first + const cached = await this.cacheService.getClientData(clientId); + if (cached) { + return cached; + } + + const response = await this.connectionService.getClientDetails(clientId); + if (!response || !response.client) { + throw new NotFoundException(`Client ${clientId} not found`); + } + + const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); + await this.cacheService.setClientData(client.id, client); + return client; + } +} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts index 21a718af..a36d7c7f 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts @@ -86,35 +86,6 @@ export class WhmcsClientService { } } - /** - * Get client details by email - * Returns WhmcsClient (type inferred from domain mapper) - */ - async getClientDetailsByEmail(email: string): Promise { - try { - const response = await this.connectionService.getClientDetailsByEmail(email); - - if (!response || !response.client) { - this.logger.error(`WHMCS API did not return client data for email: ${email}`, { - hasResponse: !!response, - responseKeys: response ? Object.keys(response) : [], - }); - throw new NotFoundException(`Client with email ${email} not found`); - } - - const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); - await this.cacheService.setClientData(client.id, client); - - this.logger.log(`Fetched client details by email: ${email}`); - return client; - } catch (error) { - this.logger.error(`Failed to fetch client details by email: ${email}`, { - error: getErrorMessage(error), - }); - throw error; - } - } - /** * Update client details */ diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index f0fbf54d..7ad3e55f 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -123,11 +123,40 @@ export class WhmcsSubscriptionService { return cached; } - // Get all subscriptions and find the specific one - const subscriptionList = await this.getSubscriptions(clientId, userId); - const subscription = subscriptionList.subscriptions.find( - (s: Subscription) => s.id === subscriptionId - ); + // 2. Check if we have the FULL list cached. + // If we do, searching memory is faster than an API call. + const cachedList = await this.cacheService.getSubscriptionsList(userId); + if (cachedList) { + const found = cachedList.subscriptions.find((s: Subscription) => s.id === subscriptionId); + if (found) { + this.logger.debug( + `Cache hit (via list) for subscription: user ${userId}, subscription ${subscriptionId}` + ); + // Cache this individual item for faster direct access next time + await this.cacheService.setSubscription(userId, subscriptionId, found); + return found; + } + // If list is cached but item not found, it might be new or not in that list? + // Proceed to fetch single item. + } + + // 3. Fetch ONLY this subscription from WHMCS (Optimized) + // Instead of fetching all products, use serviceid filter + const params: WhmcsGetClientsProductsParams = { + clientid: clientId, + serviceid: subscriptionId, + }; + + const rawResponse = await this.connectionService.getClientsProducts(params); + + // Transform response + const defaultCurrency = this.currencyService.getDefaultCurrency(); + const resultList = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, { + defaultCurrencyCode: defaultCurrency.code, + defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, + }); + + const subscription = resultList.subscriptions.find(s => s.id === subscriptionId); if (!subscription) { throw new NotFoundException(`Subscription ${subscriptionId} not found`); diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index f93d1214..da982bd6 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -10,6 +10,7 @@ import { WhmcsPaymentService } from "./services/whmcs-payment.service.js"; import { WhmcsSsoService } from "./services/whmcs-sso.service.js"; import { WhmcsOrderService } from "./services/whmcs-order.service.js"; import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js"; +import { WhmcsAccountDiscoveryService } from "./services/whmcs-account-discovery.service.js"; // Connection services import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js"; @@ -33,6 +34,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand WhmcsSsoService, WhmcsOrderService, WhmcsCurrencyService, + WhmcsAccountDiscoveryService, WhmcsService, ], exports: [ @@ -43,6 +45,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand WhmcsOrderService, WhmcsPaymentService, WhmcsCurrencyService, + WhmcsAccountDiscoveryService, ], }) export class WhmcsModule {} diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 408a7412..487606d3 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -131,14 +131,6 @@ export class WhmcsService { return this.clientService.getClientDetails(clientId); } - /** - * Get client details by email - * Returns internal WhmcsClient (type inferred) - */ - async getClientDetailsByEmail(email: string): Promise { - return this.clientService.getClientDetailsByEmail(email); - } - /** * Update client details in WHMCS */ diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index 504e69a0..f0d62cd1 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -4,6 +4,7 @@ import * as argon2 from "argon2"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; @@ -39,6 +40,7 @@ export class AuthFacade { private readonly mappingsService: MappingsService, private readonly configService: ConfigService, private readonly whmcsService: WhmcsService, + private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceService: SalesforceService, private readonly auditService: AuditService, private readonly tokenBlacklistService: TokenBlacklistService, @@ -418,14 +420,9 @@ export class AuthFacade { if (mapped) { whmcsExists = true; } else { - // Try a direct WHMCS lookup by email (best-effort) - try { - const client = await this.whmcsService.getClientDetailsByEmail(normalized); - whmcsExists = !!client; - } catch (e) { - // Treat not found as no; other errors as unknown (leave whmcsExists false) - this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) }); - } + // Try a direct WHMCS lookup by email using discovery service (returns null if not found) + const client = await this.discoveryService.findClientByEmail(normalized); + whmcsExists = !!client; } let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none"; diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index f405fdea..e15240fc 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -4,7 +4,6 @@ import { HttpStatus, Inject, Injectable, - NotFoundException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; @@ -14,6 +13,7 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; import { PrismaService } from "@bff/infra/database/prisma.service.js"; @@ -58,6 +58,7 @@ export class SignupWorkflowService { private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, + private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceService: SalesforceService, private readonly salesforceAccountService: SalesforceAccountService, private readonly configService: ConfigService, @@ -295,22 +296,15 @@ export class SignupWorkflowService { let whmcsClient: { clientId: number }; try { - try { - const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email); - if (existingWhmcs) { - const existingMapping = await this.mappingsService.findByWhmcsClientId( - existingWhmcs.id - ); - if (existingMapping) { - throw new ConflictException("You already have an account. Please sign in."); - } + // Check if a WHMCS client already exists for this email using discovery service + const existingWhmcs = await this.discoveryService.findClientByEmail(email); + if (existingWhmcs) { + const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id); + if (existingMapping) { + throw new ConflictException("You already have an account. Please sign in."); + } - throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT); - } - } catch (pre) { - if (!(pre instanceof NotFoundException)) { - throw pre; - } + throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT); } const customerNumberFieldId = this.configService.get( @@ -538,36 +532,28 @@ export class SignupWorkflowService { } if (!normalizedCustomerNumber) { - try { - const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail); - if (client) { - result.whmcs.clientExists = true; - result.whmcs.clientId = client.id; + // Check for existing WHMCS client using discovery service (returns null if not found) + const client = await this.discoveryService.findClientByEmail(normalizedEmail); + if (client) { + result.whmcs.clientExists = true; + result.whmcs.clientId = client.id; - try { - const mapped = await this.mappingsService.findByWhmcsClientId(client.id); - if (mapped) { - result.nextAction = "login"; - result.messages.push("This billing account is already linked. Please sign in."); - return result; - } - } catch { - // ignore; treat as unmapped + try { + const mapped = await this.mappingsService.findByWhmcsClientId(client.id); + if (mapped) { + result.nextAction = "login"; + result.messages.push("This billing account is already linked. Please sign in."); + return result; } + } catch { + // ignore; treat as unmapped + } - result.nextAction = "link_whmcs"; - result.messages.push( - "We found an existing billing account for this email. Please transfer your account to continue." - ); - return result; - } - } catch (err) { - if (!(err instanceof NotFoundException)) { - this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) }); - result.messages.push("Unable to verify billing system. Please try again later."); - result.nextAction = "blocked"; - return result; - } + result.nextAction = "link_whmcs"; + result.messages.push( + "We found an existing billing account for this email. Please transfer your account to continue." + ); + return result; } try { @@ -605,36 +591,28 @@ export class SignupWorkflowService { return result; } - try { - const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail); - if (client) { - result.whmcs.clientExists = true; - result.whmcs.clientId = client.id; + // Check for existing WHMCS client using discovery service (returns null if not found) + const client = await this.discoveryService.findClientByEmail(normalizedEmail); + if (client) { + result.whmcs.clientExists = true; + result.whmcs.clientId = client.id; - try { - const mapped = await this.mappingsService.findByWhmcsClientId(client.id); - if (mapped) { - result.nextAction = "login"; - result.messages.push("This billing account is already linked. Please sign in."); - return result; - } - } catch { - // ignore; treat as unmapped + try { + const mapped = await this.mappingsService.findByWhmcsClientId(client.id); + if (mapped) { + result.nextAction = "login"; + result.messages.push("This billing account is already linked. Please sign in."); + return result; } + } catch { + // ignore; treat as unmapped + } - result.nextAction = "link_whmcs"; - result.messages.push( - "We found an existing billing account for this email. Please transfer your account to continue." - ); - return result; - } - } catch (err) { - if (!(err instanceof NotFoundException)) { - this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) }); - result.messages.push("Unable to verify billing system. Please try again later."); - result.nextAction = "blocked"; - return result; - } + result.nextAction = "link_whmcs"; + result.messages.push( + "We found an existing billing account for this email. Please transfer your account to continue." + ); + return result; } result.canProceed = true; diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index 11244409..024f1ef6 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; @@ -26,6 +27,7 @@ export class WhmcsLinkWorkflowService { private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, + private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceService: SalesforceService, @Inject(Logger) private readonly logger: Logger ) {} @@ -51,21 +53,19 @@ export class WhmcsLinkWorkflowService { try { let clientDetails; // Type inferred from WHMCS service try { - clientDetails = await this.whmcsService.getClientDetailsByEmail(email); - } catch (error) { - this.logger.error("WHMCS client lookup failed", { - error: getErrorMessage(error), - email, // Safe to log email for debugging since it's not sensitive - }); - - // Provide more specific error messages based on the error type - // Use BadRequestException (400) instead of UnauthorizedException (401) - // to avoid triggering "session expired" logic in the frontend - if (error instanceof Error && error.message.includes("not found")) { + clientDetails = await this.discoveryService.findClientByEmail(email); + if (!clientDetails) { throw new BadRequestException( "No billing account found with this email address. Please check your email or contact support." ); } + } catch (error) { + if (error instanceof BadRequestException) throw error; + + this.logger.error("WHMCS client lookup failed", { + error: getErrorMessage(error), + email, // Safe to log email for debugging since it's not sensitive + }); throw new BadRequestException("Unable to verify account. Please try again later."); } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index 5eb17735..1525e1d1 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -52,7 +52,7 @@ export class GlobalAuthGuard implements CanActivate { try { await this.attachUserFromToken(request, token); this.logger.debug(`Authenticated session detected on public route: ${route}`); - } catch (error) { + } catch (_error) { // Public endpoints should remain accessible even if the session is missing/expired/invalid. this.logger.debug(`Ignoring invalid session on public route: ${route}`); } diff --git a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts new file mode 100644 index 00000000..ecb4548b --- /dev/null +++ b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { InternetCancellationService } from "./services/internet-cancellation.service.js"; +import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; +import { EmailModule } from "@bff/infra/email/email.module.js"; + +@Module({ + imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule], + providers: [InternetCancellationService], + exports: [InternetCancellationService], +}) +export class InternetManagementModule {} diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts new file mode 100644 index 00000000..5bf351bf --- /dev/null +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -0,0 +1,307 @@ +/** + * Internet Cancellation Service + * + * Handles Internet service cancellation flows: + * - Preview available cancellation months + * - Submit cancellation requests (creates SF Case + updates Opportunity) + * + * Internet cancellation differs from SIM in that: + * - No Freebit/MVNO API calls needed + * - Cancellation is processed via Salesforce Case workflow + * - Equipment return may be required (ONU, router) + */ + +import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { EmailService } from "@bff/infra/email/email.service.js"; +import type { + InternetCancellationPreview, + InternetCancellationMonth, + InternetCancelRequest, +} from "@customer-portal/domain/subscriptions"; +import { + type CancellationOpportunityData, + CANCELLATION_NOTICE, + LINE_RETURN_STATUS, +} from "@customer-portal/domain/opportunity"; + +@Injectable() +export class InternetCancellationService { + constructor( + private readonly whmcsService: WhmcsService, + private readonly mappingsService: MappingsService, + private readonly caseService: SalesforceCaseService, + private readonly opportunityService: SalesforceOpportunityService, + private readonly emailService: EmailService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Generate available cancellation months (next 12 months) + * Following the 25th rule: if before 25th, current month is available + */ + private generateCancellationMonths(): InternetCancellationMonth[] { + const months: InternetCancellationMonth[] = []; + const today = new Date(); + const dayOfMonth = today.getDate(); + + // Start from current month if before 25th, otherwise next month + const startOffset = dayOfMonth <= 25 ? 0 : 1; + + for (let i = startOffset; i < startOffset + 12; i++) { + const date = new Date(today.getFullYear(), today.getMonth() + i, 1); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const monthStr = String(month).padStart(2, "0"); + + months.push({ + value: `${year}-${monthStr}`, + label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }), + }); + } + + return months; + } + + /** + * Validate that the subscription belongs to the user and is an Internet service + */ + private async validateInternetSubscription( + userId: string, + subscriptionId: number + ): Promise<{ + whmcsClientId: number; + sfAccountId: string; + subscription: { + id: number; + productName: string; + amount: number; + nextDue?: string; + registrationDate?: string; + }; + }> { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId || !mapping?.sfAccountId) { + throw new BadRequestException("Account mapping not found"); + } + + // Get subscription from WHMCS + const productsResponse = await this.whmcsService.getClientsProducts({ + clientid: mapping.whmcsClientId, + }); + const productContainer = productsResponse.products?.product; + const products = Array.isArray(productContainer) + ? productContainer + : productContainer + ? [productContainer] + : []; + + const subscription = products.find( + (p: { id?: number | string }) => Number(p.id) === subscriptionId + ); + + if (!subscription) { + throw new NotFoundException("Subscription not found"); + } + + // Verify it's an Internet service + // Match: "Internet", "SonixNet via NTT Optical Fiber", or any NTT-based fiber service + const productName = String(subscription.name || subscription.groupname || ""); + const lowerName = productName.toLowerCase(); + const isInternetService = + lowerName.includes("internet") || + lowerName.includes("sonixnet") || + (lowerName.includes("ntt") && lowerName.includes("fiber")); + + if (!isInternetService) { + throw new BadRequestException("This endpoint is only for Internet subscriptions"); + } + + return { + whmcsClientId: mapping.whmcsClientId, + sfAccountId: mapping.sfAccountId, + subscription: { + id: Number(subscription.id), + productName: productName, + amount: parseFloat(String(subscription.amount || subscription.recurringamount || 0)), + nextDue: String(subscription.nextduedate || ""), + registrationDate: String(subscription.regdate || ""), + }, + }; + } + + /** + * Get cancellation preview with available months and service details + */ + async getCancellationPreview( + userId: string, + subscriptionId: number + ): Promise { + const { whmcsClientId, subscription } = await this.validateInternetSubscription( + userId, + subscriptionId + ); + + // Get customer info from WHMCS + const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId); + const customerName = + `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; + const customerEmail = clientDetails.email || ""; + + return { + productName: subscription.productName, + billingAmount: subscription.amount, + nextDueDate: subscription.nextDue, + registrationDate: subscription.registrationDate, + availableMonths: this.generateCancellationMonths(), + customerEmail, + customerName, + }; + } + + /** + * Submit Internet cancellation request + * + * Creates a Salesforce Case and updates the Opportunity (if found) + */ + async submitCancellation( + userId: string, + subscriptionId: number, + request: InternetCancelRequest + ): Promise { + const { whmcsClientId, sfAccountId, subscription } = await this.validateInternetSubscription( + userId, + subscriptionId + ); + + // Validate confirmations + if (!request.confirmRead || !request.confirmCancel) { + throw new BadRequestException("You must confirm both checkboxes to proceed"); + } + + // Parse cancellation month and calculate end date + const [year, month] = request.cancellationMonth.split("-").map(Number); + if (!year || !month) { + throw new BadRequestException("Invalid cancellation month format"); + } + + // Cancellation date is end of selected month + const lastDayOfMonth = new Date(year, month, 0); + const cancellationDate = lastDayOfMonth.toISOString().split("T")[0]; + + this.logger.log("Processing Internet cancellation request", { + userId, + subscriptionId, + cancellationMonth: request.cancellationMonth, + cancellationDate, + }); + + // Get customer info for notifications + const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId); + const customerName = + `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; + const customerEmail = clientDetails.email || ""; + + // Find existing Opportunity for this subscription (by WHMCS Service ID) + let opportunityId: string | null = null; + try { + opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId); + } catch { + // Opportunity lookup failure is not fatal - we'll create Case without link + this.logger.warn("Could not find Opportunity for subscription", { subscriptionId }); + } + + // Create Salesforce Case for cancellation + const caseId = await this.caseService.createCancellationCase({ + accountId: sfAccountId, + opportunityId: opportunityId || undefined, + whmcsServiceId: subscriptionId, + productType: "Internet", + cancellationMonth: request.cancellationMonth, + cancellationDate, + alternativeEmail: request.alternativeEmail || undefined, + comments: request.comments, + }); + + this.logger.log("Cancellation case created", { + caseId, + opportunityId, + }); + + // Update Opportunity if found + if (opportunityId) { + try { + const cancellationData: CancellationOpportunityData = { + scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`, + cancellationNotice: CANCELLATION_NOTICE.RECEIVED, + lineReturnStatus: LINE_RETURN_STATUS.NOT_YET, + }; + + await this.opportunityService.updateCancellationData(opportunityId, cancellationData); + + this.logger.log("Opportunity updated with cancellation data", { + opportunityId, + scheduledDate: cancellationDate, + }); + } catch (error) { + // Log but don't fail - Case was already created + this.logger.error("Failed to update Opportunity cancellation data", { + error: error instanceof Error ? error.message : "Unknown error", + opportunityId, + }); + } + } + + // Send confirmation email to customer + const confirmationSubject = "SonixNet Internet Cancellation Confirmation"; + const confirmationBody = `Dear ${customerName}, + +Your cancellation request for your Internet service has been received. + +Service: ${subscription.productName} +Cancellation effective: End of ${request.cancellationMonth} + +Our team will contact you regarding equipment return (ONU/router) if applicable. + +If you have any questions, please contact us at info@asolutions.co.jp + +With best regards, +Assist Solutions Customer Support +TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) +Email: info@asolutions.co.jp`; + + try { + await this.emailService.sendEmail({ + to: customerEmail, + subject: confirmationSubject, + text: confirmationBody, + }); + + // Send to alternative email if provided + if (request.alternativeEmail && request.alternativeEmail !== customerEmail) { + await this.emailService.sendEmail({ + to: request.alternativeEmail, + subject: confirmationSubject, + text: confirmationBody, + }); + } + } catch (error) { + // Log but don't fail - Case was already created + this.logger.error("Failed to send cancellation confirmation email", { + error: error instanceof Error ? error.message : "Unknown error", + customerEmail, + }); + } + + this.logger.log("Internet cancellation request processed successfully", { + userId, + subscriptionId, + caseId, + cancellationMonth: request.cancellationMonth, + }); + } +} diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index f0c6b8df..95558164 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -51,6 +51,12 @@ import { type ReissueSimRequest, } from "./sim-management/services/esim-management.service.js"; import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service.js"; +import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js"; +import { + internetCancelRequestSchema, + type InternetCancelRequest, + type SimActionResponse as SubscriptionActionResponse, +} from "@customer-portal/domain/subscriptions"; const subscriptionInvoiceQuerySchema = createPaginationSchema({ defaultLimit: 10, @@ -68,7 +74,8 @@ export class SubscriptionsController { private readonly simPlanService: SimPlanService, private readonly simCancellationService: SimCancellationService, private readonly esimManagementService: EsimManagementService, - private readonly simCallHistoryService: SimCallHistoryService + private readonly simCallHistoryService: SimCallHistoryService, + private readonly internetCancellationService: InternetCancellationService ) {} @Get() @@ -377,6 +384,41 @@ export class SubscriptionsController { } } + // ==================== Internet Management Endpoints ==================== + + /** + * Get Internet cancellation preview (available months, service details) + */ + @Get(":id/internet/cancellation-preview") + @Header("Cache-Control", "private, max-age=60") + async getInternetCancellationPreview( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number + ) { + const preview = await this.internetCancellationService.getCancellationPreview( + req.user.id, + subscriptionId + ); + return { success: true, data: preview }; + } + + /** + * Submit Internet cancellation request + */ + @Post(":id/internet/cancel") + @UsePipes(new ZodValidationPipe(internetCancelRequestSchema)) + async cancelInternet( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Body() body: InternetCancelRequest + ): Promise { + await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body); + return { + success: true, + message: `Internet cancellation scheduled for end of ${body.cancellationMonth}`, + }; + } + // ==================== Call/SMS History Endpoints ==================== /** diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts index 13f92bf6..0a0c68ca 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts @@ -10,9 +10,17 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js"; 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"; @Module({ - imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule], + imports: [ + WhmcsModule, + MappingsModule, + FreebitModule, + EmailModule, + SimManagementModule, + InternetManagementModule, + ], controllers: [SubscriptionsController, SimOrdersController], providers: [ SubscriptionsService, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 854cdfcf..557a88a8 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -12,6 +12,7 @@ import type { } from "@customer-portal/domain/subscriptions"; import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { Logger } from "nestjs-pino"; import type { Providers } from "@customer-portal/domain/subscriptions"; @@ -26,6 +27,7 @@ export interface GetSubscriptionsOptions { export class SubscriptionsService { constructor( private readonly whmcsService: WhmcsService, + private readonly cacheService: WhmcsCacheService, private readonly mappingsService: MappingsService, @Inject(Logger) private readonly logger: Logger ) {} @@ -316,6 +318,20 @@ export class SubscriptionsService { const batchSize = Math.min(100, Math.max(limit, 25)); try { + // Try cache first + const cached = await this.cacheService.getSubscriptionInvoices( + userId, + subscriptionId, + page, + limit + ); + if (cached) { + this.logger.debug( + `Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}` + ); + return cached; + } + // Validate subscription exists and belongs to user await this.getSubscriptionById(userId, subscriptionId); @@ -380,6 +396,9 @@ export class SubscriptionsService { } ); + // Cache the result + await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result); + return result; } catch (error) { this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, { diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index ff8a6f01..da214de0 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -204,9 +204,13 @@ export class UserProfileService { return summary; } - const [subscriptionsData, invoicesData] = await Promise.allSettled([ + const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([ this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), - this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }), + this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }), + this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { + status: "Unpaid", + limit: 1, + }), ]); let activeSubscriptions = 0; @@ -256,12 +260,25 @@ export class UserProfileService { paidDate?: string; currency?: string | null; }> = []; + + // Process unpaid invoices count + if (unpaidInvoicesData.status === "fulfilled") { + unpaidInvoices = unpaidInvoicesData.value.pagination.totalItems; + } else { + this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, { + reason: getErrorMessage(unpaidInvoicesData.reason), + }); + } + if (invoicesData.status === "fulfilled") { const invoices: Invoice[] = invoicesData.value.invoices; - unpaidInvoices = invoices.filter( - inv => inv.status === "Unpaid" || inv.status === "Overdue" - ).length; + // Fallback if unpaid invoices call failed, though inaccurate for total count > 10 + if (unpaidInvoicesData.status === "rejected") { + unpaidInvoices = invoices.filter( + inv => inv.status === "Unpaid" || inv.status === "Overdue" + ).length; + } const upcomingInvoices = invoices .filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate) diff --git a/apps/portal/src/app/account/services/[id]/internet/cancel/page.tsx b/apps/portal/src/app/account/services/[id]/internet/cancel/page.tsx new file mode 100644 index 00000000..e1684c84 --- /dev/null +++ b/apps/portal/src/app/account/services/[id]/internet/cancel/page.tsx @@ -0,0 +1,5 @@ +import InternetCancelContainer from "@/features/subscriptions/views/InternetCancel"; + +export default function AccountInternetCancelPage() { + return ; +} diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index 88a1e8eb..d5e133f0 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { @@ -9,13 +9,19 @@ import { CheckIcon, XMarkIcon, UserIcon, + ShieldCheckIcon, } from "@heroicons/react/24/outline"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { accountService } from "@/features/account/services/account.service"; import { useProfileEdit } from "@/features/account/hooks/useProfileEdit"; import { AddressForm } from "@/features/catalog/components/base/AddressForm"; import { Button } from "@/components/atoms/button"; +import { StatusPill } from "@/components/atoms/status-pill"; import { useAddressEdit } from "@/features/account/hooks/useAddressEdit"; +import { + useResidenceCardVerification, + useSubmitResidenceCard, +} from "@/features/verification/hooks/useResidenceCardVerification"; import { PageLayout } from "@/components/templates"; export default function ProfileContainer() { @@ -43,6 +49,14 @@ export default function ProfileContainer() { phoneCountryCode: "", }); + // ID Verification status from Salesforce + const verificationQuery = useResidenceCardVerification(); + const submitResidenceCard = useSubmitResidenceCard(); + const verificationStatus = verificationQuery.data?.status; + const [verificationFile, setVerificationFile] = useState(null); + const verificationFileInputRef = useRef(null); + const canUploadVerification = verificationStatus !== "verified"; + // Extract stable setValue functions to avoid infinite re-render loop. // The hook objects (address, profile) are recreated every render, but // the setValue callbacks inside them are stable (memoized with useCallback). @@ -72,15 +86,15 @@ export default function ProfileContainer() { if (prof) { setProfileValue("email", prof.email || ""); setProfileValue("phonenumber", prof.phonenumber || ""); + // Spread all profile fields into the user state, including sfNumber, dateOfBirth, gender useAuthStore.setState(state => ({ ...state, user: state.user ? { ...state.user, - email: prof.email || state.user.email, - phonenumber: prof.phonenumber || state.user.phonenumber, + ...prof, } - : (prof as unknown as typeof state.user), + : prof, })); } } catch (e) { @@ -475,6 +489,169 @@ export default function ProfileContainer() { )} + + {/* ID Verification Card - Integrated Upload */} +
+
+
+
+ +

Identity Verification

+
+ {verificationQuery.isLoading ? ( + + ) : verificationStatus === "verified" ? ( + + ) : verificationStatus === "pending" ? ( + + ) : verificationStatus === "rejected" ? ( + + ) : ( + + )} +
+
+ +
+ {verificationQuery.isLoading ? ( +
+ + +
+ ) : verificationStatus === "verified" ? ( +
+

+ Your identity has been verified. No further action is needed. +

+ {verificationQuery.data?.reviewedAt && ( +

+ Verified on{" "} + {new Date(verificationQuery.data.reviewedAt).toLocaleDateString(undefined, { + dateStyle: "medium", + })} +

+ )} +
+ ) : verificationStatus === "pending" ? ( +
+ + Your residence card has been submitted. We'll verify it before activating SIM + service. + + {(verificationQuery.data?.filename || verificationQuery.data?.submittedAt) && ( +
+
+ Submitted document +
+ {verificationQuery.data?.filename && ( +
+ {verificationQuery.data.filename} +
+ )} + {verificationQuery.data?.submittedAt && ( +
+ Submitted on{" "} + {new Date(verificationQuery.data.submittedAt).toLocaleDateString(undefined, { + dateStyle: "medium", + })} +
+ )} +
+ )} +
+ ) : ( +
+ {verificationStatus === "rejected" ? ( + +
+ {verificationQuery.data?.reviewerNotes && ( +

{verificationQuery.data.reviewerNotes}

+ )} +

Please upload a new, clear photo of your residence card.

+
+
+ ) : ( +

+ Upload your residence card to activate SIM services. This is required for SIM + orders. +

+ )} + + {canUploadVerification && ( +
+ setVerificationFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {verificationFile && ( +
+
+
+ Selected file +
+
+ {verificationFile.name} +
+
+ +
+ )} + +
+ +
+ + {submitResidenceCard.isError && ( +

+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +

+ )} + +

+ Accepted formats: JPG, PNG, or PDF. Make sure all text is readable. +

+
+ )} +
+ )} +
+
); } diff --git a/apps/portal/src/features/subscriptions/services/internet-actions.service.ts b/apps/portal/src/features/subscriptions/services/internet-actions.service.ts new file mode 100644 index 00000000..324e6a90 --- /dev/null +++ b/apps/portal/src/features/subscriptions/services/internet-actions.service.ts @@ -0,0 +1,52 @@ +import { apiClient } from "@/lib/api"; +import type { + InternetCancellationPreview, + InternetCancelRequest, +} from "@customer-portal/domain/subscriptions"; + +export interface InternetCancellationMonth { + value: string; + label: string; +} + +export interface InternetCancellationPreviewResponse { + productName: string; + billingAmount: number; + nextDueDate?: string; + registrationDate?: string; + availableMonths: InternetCancellationMonth[]; + customerEmail: string; + customerName: string; +} + +export const internetActionsService = { + /** + * Get cancellation preview (available months, service details) + */ + async getCancellationPreview( + subscriptionId: string + ): Promise { + const response = await apiClient.GET<{ + success: boolean; + data: InternetCancellationPreview; + }>("/api/subscriptions/{subscriptionId}/internet/cancellation-preview", { + params: { path: { subscriptionId } }, + }); + + if (!response.data?.data) { + throw new Error("Failed to load cancellation information"); + } + + return response.data.data; + }, + + /** + * Submit Internet cancellation request + */ + async submitCancellation(subscriptionId: string, request: InternetCancelRequest): Promise { + await apiClient.POST("/api/subscriptions/{subscriptionId}/internet/cancel", { + params: { path: { subscriptionId } }, + body: request, + }); + }, +}; diff --git a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx new file mode 100644 index 00000000..14ed1267 --- /dev/null +++ b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx @@ -0,0 +1,405 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState, type ReactNode } from "react"; +import { + internetActionsService, + type InternetCancellationPreviewResponse, +} from "@/features/subscriptions/services/internet-actions.service"; +import { PageLayout } from "@/components/templates/PageLayout"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms"; +import { GlobeAltIcon } from "@heroicons/react/24/outline"; + +type Step = 1 | 2 | 3; + +function Notice({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function InternetCancelContainer() { + const params = useParams(); + const router = useRouter(); + const subscriptionId = params.id as string; + + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [acceptTerms, setAcceptTerms] = useState(false); + const [confirmMonthEnd, setConfirmMonthEnd] = useState(false); + const [selectedMonth, setSelectedMonth] = useState(""); + const [alternativeEmail, setAlternativeEmail] = useState(""); + const [alternativeEmail2, setAlternativeEmail2] = useState(""); + const [comments, setComments] = useState(""); + const [loadingPreview, setLoadingPreview] = useState(true); + + 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 { + setLoadingPreview(false); + } + }; + void fetchPreview(); + }, [subscriptionId]); + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0; + const emailValid = + !emailProvided || + (emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim())); + const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim(); + const canProceedStep2 = !!preview && !!selectedMonth; + const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch; + + const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth); + + const formatCurrency = (amount: number) => { + return `¥${amount.toLocaleString()}`; + }; + + const submit = async () => { + setLoading(true); + setError(null); + setMessage(null); + + if (!selectedMonth) { + setError("Please select a cancellation month before submitting."); + setLoading(false); + return; + } + + try { + await internetActionsService.submitCancellation(subscriptionId, { + cancellationMonth: selectedMonth, + confirmRead: acceptTerms, + confirmCancel: confirmMonthEnd, + alternativeEmail: alternativeEmail.trim() || undefined, + comments: comments.trim() || undefined, + }); + setMessage("Cancellation request submitted. You will receive a confirmation email."); + setTimeout(() => router.push(`/account/services/${subscriptionId}`), 2000); + } catch (e: unknown) { + setError( + 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 { + setLoading(false); + } + }; + + const isBlockingError = !loadingPreview && !preview && Boolean(error); + const pageError = isBlockingError ? error : null; + + return ( + } + title="Cancel Internet" + description="Cancel your Internet subscription" + breadcrumbs={[ + { label: "Services", href: "/account/services" }, + { label: preview?.productName || "Internet", href: `/account/services/${subscriptionId}` }, + { label: "Cancel" }, + ]} + loading={loadingPreview} + error={pageError} + > + {preview ? ( +
+
+ + ← Back to Service Details + +
+ {[1, 2, 3].map(s => ( +
+ ))} +
+
Step {step} of 3
+
+ + {error && !isBlockingError ? ( + + {error} + + ) : null} + {message ? ( + + {message} + + ) : null} + + +

Cancel Internet Service

+

+ Cancel your Internet subscription. Please read all the information carefully before + proceeding. +

+ + {step === 1 && ( +
+ {/* Service Info */} +
+ + + +
+ + {/* Month Selection */} +
+ + +

+ Your subscription will be cancelled at the end of the selected month. +

+
+ +
+ +
+
+ )} + + {step === 2 && ( +
+
+ + Online cancellations must be submitted by the 25th of the desired cancellation + month. Once your cancellation request is accepted, a confirmation email will be + sent to your registered email address. Our team will contact you regarding + equipment return (ONU/router) if applicable. + + + + Internet equipment (ONU, router) is typically rental hardware and must be + returned to Assist Solutions or NTT upon cancellation. Our team will provide + instructions for equipment return after processing your request. + + + + You will be billed for service through the end of your cancellation month. Any + outstanding balance or prorated charges will be processed according to your + billing cycle. + +
+ +
+
+ setAcceptTerms(e.target.checked)} + className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring" + /> + +
+ +
+ setConfirmMonthEnd(e.target.checked)} + disabled={!selectedMonth} + className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring" + /> + +
+
+ +
+ + +
+
+ )} + + {step === 3 && ( +
+ {/* Confirmation Summary */} +
+
+ Cancellation Summary +
+
+
+ Service: {preview?.productName} +
+
+ Cancellation effective: End of{" "} + {selectedMonthInfo?.label || selectedMonth} +
+
+
+ + {/* Registered Email */} +
+ Your registered email address is:{" "} + {preview?.customerEmail || "—"} +
+
+ You will receive a cancellation confirmation email. If you would like to receive + this email on a different address, please enter the address below. +
+ + {/* Alternative Email */} +
+
+ + setAlternativeEmail(e.target.value)} + placeholder="you@example.com" + /> +
+
+ + setAlternativeEmail2(e.target.value)} + placeholder="you@example.com" + /> +
+
+ + {emailProvided && !emailValid && ( +
+ Please enter a valid email address in both fields. +
+ )} + {emailProvided && emailValid && !emailsMatch && ( +
Email addresses do not match.
+ )} + + {/* Comments */} +
+ +