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.
This commit is contained in:
parent
4d645adcdd
commit
4573b94484
@ -42,11 +42,21 @@ export class WhmcsCacheService {
|
|||||||
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
||||||
tags: ["subscription", "services"],
|
tags: ["subscription", "services"],
|
||||||
},
|
},
|
||||||
|
subscriptionInvoices: {
|
||||||
|
prefix: "whmcs:subscription:invoices",
|
||||||
|
ttl: 300, // 5 minutes
|
||||||
|
tags: ["subscription", "invoices"],
|
||||||
|
},
|
||||||
client: {
|
client: {
|
||||||
prefix: "whmcs:client",
|
prefix: "whmcs:client",
|
||||||
ttl: 1800, // 30 minutes - client data rarely changes
|
ttl: 1800, // 30 minutes - client data rarely changes
|
||||||
tags: ["client", "user"],
|
tags: ["client", "user"],
|
||||||
},
|
},
|
||||||
|
clientEmail: {
|
||||||
|
prefix: "whmcs:client:email",
|
||||||
|
ttl: 1800, // 30 minutes
|
||||||
|
tags: ["client", "email"],
|
||||||
|
},
|
||||||
sso: {
|
sso: {
|
||||||
prefix: "whmcs:sso",
|
prefix: "whmcs:sso",
|
||||||
ttl: 3600, // 1 hour - SSO tokens have their own expiry
|
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}`]);
|
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<InvoiceList | null> {
|
||||||
|
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
|
||||||
|
return this.get<InvoiceList>(key, "subscriptionInvoices");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache subscription invoices
|
||||||
|
*/
|
||||||
|
async setSubscriptionInvoices(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
data: InvoiceList
|
||||||
|
): Promise<void> {
|
||||||
|
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
|
||||||
|
await this.set(key, data, "subscriptionInvoices", [
|
||||||
|
`user:${userId}`,
|
||||||
|
`subscription:${subscriptionId}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached client data
|
* Get cached client data
|
||||||
* Returns WhmcsClient (type inferred from domain)
|
* Returns WhmcsClient (type inferred from domain)
|
||||||
@ -161,6 +201,22 @@ export class WhmcsCacheService {
|
|||||||
await this.set(key, data, "client", [`client:${clientId}`]);
|
await this.set(key, data, "client", [`client:${clientId}`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached client ID by email
|
||||||
|
*/
|
||||||
|
async getClientIdByEmail(email: string): Promise<number | null> {
|
||||||
|
const key = this.buildClientEmailKey(email);
|
||||||
|
return this.get<number>(key, "clientEmail");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache client ID for email
|
||||||
|
*/
|
||||||
|
async setClientIdByEmail(email: string, clientId: number): Promise<void> {
|
||||||
|
const key = this.buildClientEmailKey(email);
|
||||||
|
await this.set(key, clientId, "clientEmail");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate all cache for a specific user
|
* Invalidate all cache for a specific user
|
||||||
*/
|
*/
|
||||||
@ -383,6 +439,18 @@ export class WhmcsCacheService {
|
|||||||
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`;
|
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
|
* Build cache key for client data
|
||||||
*/
|
*/
|
||||||
@ -390,6 +458,13 @@ export class WhmcsCacheService {
|
|||||||
return `${this.cacheConfigs.client.prefix}:${clientId}`;
|
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
|
* Build cache key for payment methods
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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<WhmcsClient | null> {
|
||||||
|
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<WhmcsClient> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -86,35 +86,6 @@ export class WhmcsClientService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client details by email
|
|
||||||
* Returns WhmcsClient (type inferred from domain mapper)
|
|
||||||
*/
|
|
||||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
|
|
||||||
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
|
* Update client details
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -123,11 +123,40 @@ export class WhmcsSubscriptionService {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all subscriptions and find the specific one
|
// 2. Check if we have the FULL list cached.
|
||||||
const subscriptionList = await this.getSubscriptions(clientId, userId);
|
// If we do, searching memory is faster than an API call.
|
||||||
const subscription = subscriptionList.subscriptions.find(
|
const cachedList = await this.cacheService.getSubscriptionsList(userId);
|
||||||
(s: Subscription) => s.id === subscriptionId
|
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) {
|
if (!subscription) {
|
||||||
throw new NotFoundException(`Subscription ${subscriptionId} not found`);
|
throw new NotFoundException(`Subscription ${subscriptionId} not found`);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { WhmcsPaymentService } from "./services/whmcs-payment.service.js";
|
|||||||
import { WhmcsSsoService } from "./services/whmcs-sso.service.js";
|
import { WhmcsSsoService } from "./services/whmcs-sso.service.js";
|
||||||
import { WhmcsOrderService } from "./services/whmcs-order.service.js";
|
import { WhmcsOrderService } from "./services/whmcs-order.service.js";
|
||||||
import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js";
|
import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js";
|
||||||
|
import { WhmcsAccountDiscoveryService } from "./services/whmcs-account-discovery.service.js";
|
||||||
// Connection services
|
// Connection services
|
||||||
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
|
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
|
||||||
import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js";
|
import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js";
|
||||||
@ -33,6 +34,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
|
|||||||
WhmcsSsoService,
|
WhmcsSsoService,
|
||||||
WhmcsOrderService,
|
WhmcsOrderService,
|
||||||
WhmcsCurrencyService,
|
WhmcsCurrencyService,
|
||||||
|
WhmcsAccountDiscoveryService,
|
||||||
WhmcsService,
|
WhmcsService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@ -43,6 +45,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
|
|||||||
WhmcsOrderService,
|
WhmcsOrderService,
|
||||||
WhmcsPaymentService,
|
WhmcsPaymentService,
|
||||||
WhmcsCurrencyService,
|
WhmcsCurrencyService,
|
||||||
|
WhmcsAccountDiscoveryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class WhmcsModule {}
|
export class WhmcsModule {}
|
||||||
|
|||||||
@ -131,14 +131,6 @@ export class WhmcsService {
|
|||||||
return this.clientService.getClientDetails(clientId);
|
return this.clientService.getClientDetails(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client details by email
|
|
||||||
* Returns internal WhmcsClient (type inferred)
|
|
||||||
*/
|
|
||||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
|
|
||||||
return this.clientService.getClientDetailsByEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update client details in WHMCS
|
* Update client details in WHMCS
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import * as argon2 from "argon2";
|
|||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
@ -39,6 +40,7 @@ export class AuthFacade {
|
|||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly whmcsService: WhmcsService,
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceService: SalesforceService,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||||
@ -418,14 +420,9 @@ export class AuthFacade {
|
|||||||
if (mapped) {
|
if (mapped) {
|
||||||
whmcsExists = true;
|
whmcsExists = true;
|
||||||
} else {
|
} else {
|
||||||
// Try a direct WHMCS lookup by email (best-effort)
|
// Try a direct WHMCS lookup by email using discovery service (returns null if not found)
|
||||||
try {
|
const client = await this.discoveryService.findClientByEmail(normalized);
|
||||||
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
|
whmcsExists = !!client;
|
||||||
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) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
|
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
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 { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||||
@ -58,6 +58,7 @@ export class SignupWorkflowService {
|
|||||||
private readonly usersFacade: UsersFacade,
|
private readonly usersFacade: UsersFacade,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly whmcsService: WhmcsService,
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceService: SalesforceService,
|
||||||
private readonly salesforceAccountService: SalesforceAccountService,
|
private readonly salesforceAccountService: SalesforceAccountService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@ -295,22 +296,15 @@ export class SignupWorkflowService {
|
|||||||
|
|
||||||
let whmcsClient: { clientId: number };
|
let whmcsClient: { clientId: number };
|
||||||
try {
|
try {
|
||||||
try {
|
// Check if a WHMCS client already exists for this email using discovery service
|
||||||
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
|
const existingWhmcs = await this.discoveryService.findClientByEmail(email);
|
||||||
if (existingWhmcs) {
|
if (existingWhmcs) {
|
||||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(
|
const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id);
|
||||||
existingWhmcs.id
|
if (existingMapping) {
|
||||||
);
|
throw new ConflictException("You already have an account. Please sign in.");
|
||||||
if (existingMapping) {
|
}
|
||||||
throw new ConflictException("You already have an account. Please sign in.");
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
|
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
|
||||||
}
|
|
||||||
} catch (pre) {
|
|
||||||
if (!(pre instanceof NotFoundException)) {
|
|
||||||
throw pre;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerNumberFieldId = this.configService.get<string>(
|
const customerNumberFieldId = this.configService.get<string>(
|
||||||
@ -538,36 +532,28 @@ export class SignupWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedCustomerNumber) {
|
if (!normalizedCustomerNumber) {
|
||||||
try {
|
// Check for existing WHMCS client using discovery service (returns null if not found)
|
||||||
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
const client = await this.discoveryService.findClientByEmail(normalizedEmail);
|
||||||
if (client) {
|
if (client) {
|
||||||
result.whmcs.clientExists = true;
|
result.whmcs.clientExists = true;
|
||||||
result.whmcs.clientId = client.id;
|
result.whmcs.clientId = client.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
result.nextAction = "login";
|
result.nextAction = "login";
|
||||||
result.messages.push("This billing account is already linked. Please sign in.");
|
result.messages.push("This billing account is already linked. Please sign in.");
|
||||||
return result;
|
return result;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore; treat as unmapped
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; treat as unmapped
|
||||||
|
}
|
||||||
|
|
||||||
result.nextAction = "link_whmcs";
|
result.nextAction = "link_whmcs";
|
||||||
result.messages.push(
|
result.messages.push(
|
||||||
"We found an existing billing account for this email. Please transfer your account to continue."
|
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||||
);
|
);
|
||||||
return result;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -605,36 +591,28 @@ export class SignupWorkflowService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Check for existing WHMCS client using discovery service (returns null if not found)
|
||||||
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
const client = await this.discoveryService.findClientByEmail(normalizedEmail);
|
||||||
if (client) {
|
if (client) {
|
||||||
result.whmcs.clientExists = true;
|
result.whmcs.clientExists = true;
|
||||||
result.whmcs.clientId = client.id;
|
result.whmcs.clientId = client.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
result.nextAction = "login";
|
result.nextAction = "login";
|
||||||
result.messages.push("This billing account is already linked. Please sign in.");
|
result.messages.push("This billing account is already linked. Please sign in.");
|
||||||
return result;
|
return result;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore; treat as unmapped
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; treat as unmapped
|
||||||
|
}
|
||||||
|
|
||||||
result.nextAction = "link_whmcs";
|
result.nextAction = "link_whmcs";
|
||||||
result.messages.push(
|
result.messages.push(
|
||||||
"We found an existing billing account for this email. Please transfer your account to continue."
|
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||||
);
|
);
|
||||||
return result;
|
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.canProceed = true;
|
result.canProceed = true;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||||
@ -26,6 +27,7 @@ export class WhmcsLinkWorkflowService {
|
|||||||
private readonly usersFacade: UsersFacade,
|
private readonly usersFacade: UsersFacade,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly whmcsService: WhmcsService,
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceService: SalesforceService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -51,21 +53,19 @@ export class WhmcsLinkWorkflowService {
|
|||||||
try {
|
try {
|
||||||
let clientDetails; // Type inferred from WHMCS service
|
let clientDetails; // Type inferred from WHMCS service
|
||||||
try {
|
try {
|
||||||
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
clientDetails = await this.discoveryService.findClientByEmail(email);
|
||||||
} catch (error) {
|
if (!clientDetails) {
|
||||||
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")) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"No billing account found with this email address. Please check your email or contact support."
|
"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.");
|
throw new BadRequestException("Unable to verify account. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
try {
|
try {
|
||||||
await this.attachUserFromToken(request, token);
|
await this.attachUserFromToken(request, token);
|
||||||
this.logger.debug(`Authenticated session detected on public route: ${route}`);
|
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.
|
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
|
||||||
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
|
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
@ -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<InternetCancellationPreview> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -51,6 +51,12 @@ import {
|
|||||||
type ReissueSimRequest,
|
type ReissueSimRequest,
|
||||||
} from "./sim-management/services/esim-management.service.js";
|
} from "./sim-management/services/esim-management.service.js";
|
||||||
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.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({
|
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||||
defaultLimit: 10,
|
defaultLimit: 10,
|
||||||
@ -68,7 +74,8 @@ export class SubscriptionsController {
|
|||||||
private readonly simPlanService: SimPlanService,
|
private readonly simPlanService: SimPlanService,
|
||||||
private readonly simCancellationService: SimCancellationService,
|
private readonly simCancellationService: SimCancellationService,
|
||||||
private readonly esimManagementService: EsimManagementService,
|
private readonly esimManagementService: EsimManagementService,
|
||||||
private readonly simCallHistoryService: SimCallHistoryService
|
private readonly simCallHistoryService: SimCallHistoryService,
|
||||||
|
private readonly internetCancellationService: InternetCancellationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@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<SubscriptionActionResponse> {
|
||||||
|
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 ====================
|
// ==================== Call/SMS History Endpoints ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,9 +10,17 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
|||||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
||||||
import { EmailModule } from "@bff/infra/email/email.module.js";
|
import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||||
import { SimManagementModule } from "./sim-management/sim-management.module.js";
|
import { SimManagementModule } from "./sim-management/sim-management.module.js";
|
||||||
|
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule],
|
imports: [
|
||||||
|
WhmcsModule,
|
||||||
|
MappingsModule,
|
||||||
|
FreebitModule,
|
||||||
|
EmailModule,
|
||||||
|
SimManagementModule,
|
||||||
|
InternetManagementModule,
|
||||||
|
],
|
||||||
controllers: [SubscriptionsController, SimOrdersController],
|
controllers: [SubscriptionsController, SimOrdersController],
|
||||||
providers: [
|
providers: [
|
||||||
SubscriptionsService,
|
SubscriptionsService,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type {
|
|||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
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 { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||||
@ -26,6 +27,7 @@ export interface GetSubscriptionsOptions {
|
|||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly whmcsService: WhmcsService,
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly cacheService: WhmcsCacheService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -316,6 +318,20 @@ export class SubscriptionsService {
|
|||||||
const batchSize = Math.min(100, Math.max(limit, 25));
|
const batchSize = Math.min(100, Math.max(limit, 25));
|
||||||
|
|
||||||
try {
|
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
|
// Validate subscription exists and belongs to user
|
||||||
await this.getSubscriptionById(userId, subscriptionId);
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, {
|
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, {
|
||||||
|
|||||||
@ -204,9 +204,13 @@ export class UserProfileService {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subscriptionsData, invoicesData] = await Promise.allSettled([
|
const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
|
||||||
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
|
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;
|
let activeSubscriptions = 0;
|
||||||
@ -256,12 +260,25 @@ export class UserProfileService {
|
|||||||
paidDate?: string;
|
paidDate?: string;
|
||||||
currency?: string | null;
|
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") {
|
if (invoicesData.status === "fulfilled") {
|
||||||
const invoices: Invoice[] = invoicesData.value.invoices;
|
const invoices: Invoice[] = invoicesData.value.invoices;
|
||||||
|
|
||||||
unpaidInvoices = invoices.filter(
|
// Fallback if unpaid invoices call failed, though inaccurate for total count > 10
|
||||||
inv => inv.status === "Unpaid" || inv.status === "Overdue"
|
if (unpaidInvoicesData.status === "rejected") {
|
||||||
).length;
|
unpaidInvoices = invoices.filter(
|
||||||
|
inv => inv.status === "Unpaid" || inv.status === "Overdue"
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
const upcomingInvoices = invoices
|
const upcomingInvoices = invoices
|
||||||
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
|
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
import InternetCancelContainer from "@/features/subscriptions/views/InternetCancel";
|
||||||
|
|
||||||
|
export default function AccountInternetCancelPage() {
|
||||||
|
return <InternetCancelContainer />;
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import {
|
import {
|
||||||
@ -9,13 +9,19 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
import { accountService } from "@/features/account/services/account.service";
|
||||||
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
|
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
|
||||||
import { AddressForm } from "@/features/catalog/components/base/AddressForm";
|
import { AddressForm } from "@/features/catalog/components/base/AddressForm";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { useAddressEdit } from "@/features/account/hooks/useAddressEdit";
|
import { useAddressEdit } from "@/features/account/hooks/useAddressEdit";
|
||||||
|
import {
|
||||||
|
useResidenceCardVerification,
|
||||||
|
useSubmitResidenceCard,
|
||||||
|
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||||
import { PageLayout } from "@/components/templates";
|
import { PageLayout } from "@/components/templates";
|
||||||
|
|
||||||
export default function ProfileContainer() {
|
export default function ProfileContainer() {
|
||||||
@ -43,6 +49,14 @@ export default function ProfileContainer() {
|
|||||||
phoneCountryCode: "",
|
phoneCountryCode: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ID Verification status from Salesforce
|
||||||
|
const verificationQuery = useResidenceCardVerification();
|
||||||
|
const submitResidenceCard = useSubmitResidenceCard();
|
||||||
|
const verificationStatus = verificationQuery.data?.status;
|
||||||
|
const [verificationFile, setVerificationFile] = useState<File | null>(null);
|
||||||
|
const verificationFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const canUploadVerification = verificationStatus !== "verified";
|
||||||
|
|
||||||
// Extract stable setValue functions to avoid infinite re-render loop.
|
// Extract stable setValue functions to avoid infinite re-render loop.
|
||||||
// The hook objects (address, profile) are recreated every render, but
|
// The hook objects (address, profile) are recreated every render, but
|
||||||
// the setValue callbacks inside them are stable (memoized with useCallback).
|
// the setValue callbacks inside them are stable (memoized with useCallback).
|
||||||
@ -72,15 +86,15 @@ export default function ProfileContainer() {
|
|||||||
if (prof) {
|
if (prof) {
|
||||||
setProfileValue("email", prof.email || "");
|
setProfileValue("email", prof.email || "");
|
||||||
setProfileValue("phonenumber", prof.phonenumber || "");
|
setProfileValue("phonenumber", prof.phonenumber || "");
|
||||||
|
// Spread all profile fields into the user state, including sfNumber, dateOfBirth, gender
|
||||||
useAuthStore.setState(state => ({
|
useAuthStore.setState(state => ({
|
||||||
...state,
|
...state,
|
||||||
user: state.user
|
user: state.user
|
||||||
? {
|
? {
|
||||||
...state.user,
|
...state.user,
|
||||||
email: prof.email || state.user.email,
|
...prof,
|
||||||
phonenumber: prof.phonenumber || state.user.phonenumber,
|
|
||||||
}
|
}
|
||||||
: (prof as unknown as typeof state.user),
|
: prof,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -475,6 +489,169 @@ export default function ProfileContainer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ID Verification Card - Integrated Upload */}
|
||||||
|
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||||
|
<div className="px-6 py-5 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<ShieldCheckIcon className="h-6 w-6 text-primary" />
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">Identity Verification</h2>
|
||||||
|
</div>
|
||||||
|
{verificationQuery.isLoading ? (
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
) : verificationStatus === "verified" ? (
|
||||||
|
<StatusPill label="Verified" variant="success" />
|
||||||
|
) : verificationStatus === "pending" ? (
|
||||||
|
<StatusPill label="Under Review" variant="info" />
|
||||||
|
) : verificationStatus === "rejected" ? (
|
||||||
|
<StatusPill label="Action Needed" variant="warning" />
|
||||||
|
) : (
|
||||||
|
<StatusPill label="Required for SIM" variant="warning" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{verificationQuery.isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
) : verificationStatus === "verified" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your identity has been verified. No further action is needed.
|
||||||
|
</p>
|
||||||
|
{verificationQuery.data?.reviewedAt && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Verified on{" "}
|
||||||
|
{new Date(verificationQuery.data.reviewedAt).toLocaleDateString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : verificationStatus === "pending" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AlertBanner variant="info" title="Under review" size="sm" elevated>
|
||||||
|
Your residence card has been submitted. We'll verify it before activating SIM
|
||||||
|
service.
|
||||||
|
</AlertBanner>
|
||||||
|
{(verificationQuery.data?.filename || verificationQuery.data?.submittedAt) && (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Submitted document
|
||||||
|
</div>
|
||||||
|
{verificationQuery.data?.filename && (
|
||||||
|
<div className="mt-1 text-sm font-medium text-foreground">
|
||||||
|
{verificationQuery.data.filename}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verificationQuery.data?.submittedAt && (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Submitted on{" "}
|
||||||
|
{new Date(verificationQuery.data.submittedAt).toLocaleDateString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{verificationStatus === "rejected" ? (
|
||||||
|
<AlertBanner variant="warning" title="Verification rejected" size="sm" elevated>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{verificationQuery.data?.reviewerNotes && (
|
||||||
|
<p>{verificationQuery.data.reviewerNotes}</p>
|
||||||
|
)}
|
||||||
|
<p>Please upload a new, clear photo of your residence card.</p>
|
||||||
|
</div>
|
||||||
|
</AlertBanner>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Upload your residence card to activate SIM services. This is required for SIM
|
||||||
|
orders.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canUploadVerification && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
ref={verificationFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
onChange={e => 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 && (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Selected file
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground truncate">
|
||||||
|
{verificationFile.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setVerificationFile(null);
|
||||||
|
if (verificationFileInputRef.current) {
|
||||||
|
verificationFileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!verificationFile || submitResidenceCard.isPending}
|
||||||
|
isLoading={submitResidenceCard.isPending}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => {
|
||||||
|
if (!verificationFile) return;
|
||||||
|
submitResidenceCard.mutate(verificationFile, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setVerificationFile(null);
|
||||||
|
if (verificationFileInputRef.current) {
|
||||||
|
verificationFileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit Document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitResidenceCard.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{submitResidenceCard.error instanceof Error
|
||||||
|
? submitResidenceCard.error.message
|
||||||
|
: "Failed to submit residence card."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Accepted formats: JPG, PNG, or PDF. Make sure all text is readable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<InternetCancellationPreviewResponse> {
|
||||||
|
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<void> {
|
||||||
|
await apiClient.POST("/api/subscriptions/{subscriptionId}/internet/cancel", {
|
||||||
|
params: { path: { subscriptionId } },
|
||||||
|
body: request,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
405
apps/portal/src/features/subscriptions/views/InternetCancel.tsx
Normal file
405
apps/portal/src/features/subscriptions/views/InternetCancel.tsx
Normal file
@ -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 (
|
||||||
|
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
||||||
|
<div className="text-sm font-semibold text-foreground mb-2">{title}</div>
|
||||||
|
<div className="text-sm text-muted-foreground leading-relaxed">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InternetCancelContainer() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const subscriptionId = params.id as string;
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<InternetCancellationPreviewResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||||
|
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||||
|
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
||||||
|
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
||||||
|
const [comments, setComments] = useState<string>("");
|
||||||
|
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 (
|
||||||
|
<PageLayout
|
||||||
|
icon={<GlobeAltIcon />}
|
||||||
|
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 ? (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
<div className="mb-2">
|
||||||
|
<Link
|
||||||
|
href={`/account/services/${subscriptionId}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
← Back to Service Details
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{[1, 2, 3].map(s => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
className={`h-2 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-border"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">Step {step} of 3</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !isBlockingError ? (
|
||||||
|
<AlertBanner variant="error" title="Unable to proceed" elevated>
|
||||||
|
{error}
|
||||||
|
</AlertBanner>
|
||||||
|
) : null}
|
||||||
|
{message ? (
|
||||||
|
<AlertBanner variant="success" title="Request submitted" elevated>
|
||||||
|
{message}
|
||||||
|
</AlertBanner>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SubCard>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground mb-2">Cancel Internet Service</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Cancel your Internet subscription. Please read all the information carefully before
|
||||||
|
proceeding.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Service Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-muted border border-border rounded-lg">
|
||||||
|
<InfoRow label="Service" value={preview?.productName || "—"} />
|
||||||
|
<InfoRow
|
||||||
|
label="Monthly Amount"
|
||||||
|
value={preview?.billingAmount ? formatCurrency(preview.billingAmount) : "—"}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Next Due" value={preview?.nextDueDate || "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
Select Cancellation Month
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={e => {
|
||||||
|
setSelectedMonth(e.target.value);
|
||||||
|
setConfirmMonthEnd(false);
|
||||||
|
}}
|
||||||
|
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Select month…</option>
|
||||||
|
{preview?.availableMonths.map(month => (
|
||||||
|
<option key={month.value} value={month.value}>
|
||||||
|
{month.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Your subscription will be cancelled at the end of the selected month.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button disabled={!canProceedStep2} onClick={() => setStep(2)}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Notice title="[Cancellation Procedure]">
|
||||||
|
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.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<Notice title="[Equipment Return]">
|
||||||
|
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.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<Notice title="[Final Billing]">
|
||||||
|
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.
|
||||||
|
</Notice>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 bg-muted border border-border rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
id="acceptTerms"
|
||||||
|
type="checkbox"
|
||||||
|
checked={acceptTerms}
|
||||||
|
onChange={e => setAcceptTerms(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<label htmlFor="acceptTerms" className="text-sm text-foreground/80">
|
||||||
|
I have read and understood the cancellation terms above.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
id="confirmMonthEnd"
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmMonthEnd}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<label htmlFor="confirmMonthEnd" className="text-sm text-foreground/80">
|
||||||
|
I would like to cancel my Internet subscription at the end of{" "}
|
||||||
|
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => setStep(1)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!canProceedStep3} onClick={() => setStep(3)}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Confirmation Summary */}
|
||||||
|
<div className="bg-info-soft border border-info/25 rounded-lg p-4">
|
||||||
|
<div className="text-sm font-semibold text-foreground mb-2">
|
||||||
|
Cancellation Summary
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>
|
||||||
|
<strong>Service:</strong> {preview?.productName}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Cancellation effective:</strong> End of{" "}
|
||||||
|
{selectedMonthInfo?.label || selectedMonth}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registered Email */}
|
||||||
|
<div className="text-sm text-foreground/80">
|
||||||
|
Your registered email address is:{" "}
|
||||||
|
<span className="font-medium">{preview?.customerEmail || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
You will receive a cancellation confirmation email. If you would like to receive
|
||||||
|
this email on a different address, please enter the address below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alternative Email */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Email address:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||||
|
value={alternativeEmail}
|
||||||
|
onChange={e => setAlternativeEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
(Confirm):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||||
|
value={alternativeEmail2}
|
||||||
|
onChange={e => setAlternativeEmail2(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailProvided && !emailValid && (
|
||||||
|
<div className="text-xs text-danger">
|
||||||
|
Please enter a valid email address in both fields.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{emailProvided && emailValid && !emailsMatch && (
|
||||||
|
<div className="text-xs text-danger">Email addresses do not match.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
If you have any questions or comments regarding your cancellation, please note
|
||||||
|
them below and our team will contact you.
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||||
|
rows={4}
|
||||||
|
value={comments}
|
||||||
|
onChange={e => setComments(e.target.value)}
|
||||||
|
placeholder="Optional: Enter any questions or requests here."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Final Warning */}
|
||||||
|
<div className="bg-danger-soft border border-danger/25 rounded-lg p-4">
|
||||||
|
<div className="text-sm font-semibold text-foreground mb-1">
|
||||||
|
Your cancellation request is not confirmed yet.
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
This is the final step. Click "Request Cancellation" to submit your request.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => setStep(2)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Are you sure you want to cancel your Internet service? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading || !canProceedStep3}
|
||||||
|
loading={loading}
|
||||||
|
loadingText="Processing…"
|
||||||
|
>
|
||||||
|
REQUEST CANCELLATION
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SubCard>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InternetCancelContainer;
|
||||||
@ -3,7 +3,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ServerIcon, CalendarIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
ServerIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
|
GlobeAltIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
import { useSubscription } from "@/features/subscriptions/hooks";
|
||||||
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
||||||
@ -62,7 +68,14 @@ export function SubscriptionDetailContainer() {
|
|||||||
: "Unable to load subscription details. Please try again."
|
: "Unable to load subscription details. Please try again."
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isSimService = Boolean(subscription?.productName?.toLowerCase().includes("sim"));
|
const productNameLower = subscription?.productName?.toLowerCase() ?? "";
|
||||||
|
const isSimService = productNameLower.includes("sim");
|
||||||
|
// Match: "Internet", "SonixNet via NTT Optical Fiber", or any NTT-based fiber service
|
||||||
|
const isInternetService =
|
||||||
|
productNameLower.includes("internet") ||
|
||||||
|
productNameLower.includes("sonixnet") ||
|
||||||
|
(productNameLower.includes("ntt") && productNameLower.includes("fiber"));
|
||||||
|
const canCancel = subscription?.status === "Active";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@ -187,6 +200,49 @@ export function SubscriptionDetailContainer() {
|
|||||||
<InvoicesList subscriptionId={subscriptionId} pageSize={5} showFilters={false} />
|
<InvoicesList subscriptionId={subscriptionId} pageSize={5} showFilters={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Internet Service Actions */}
|
||||||
|
{isInternetService && activeTab === "overview" && (
|
||||||
|
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-5 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<GlobeAltIcon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Service Actions</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage your Internet subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
{canCancel ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Cancel Service</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Request cancellation of your Internet subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/account/services/${subscriptionId}/internet/cancel`}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-danger-foreground bg-danger hover:bg-danger/90 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
Request Cancellation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Service actions are not available for {subscription.status.toLowerCase()}{" "}
|
||||||
|
subscriptions.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID Verification Page
|
||||||
|
*
|
||||||
|
* This is a standalone page that can be accessed directly or from checkout flows.
|
||||||
|
* For checkout flows, it accepts a returnTo query parameter to navigate back.
|
||||||
|
*
|
||||||
|
* The main verification UI is now integrated into the Profile/Settings page.
|
||||||
|
* This page is kept for:
|
||||||
|
* - Direct URL access
|
||||||
|
* - Checkout flow redirects with returnTo parameter
|
||||||
|
*/
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
@ -36,9 +48,9 @@ export function ResidenceCardVerificationSettingsView() {
|
|||||||
const status = residenceCardQuery.data?.status;
|
const status = residenceCardQuery.data?.status;
|
||||||
const statusPill = useMemo(() => {
|
const statusPill = useMemo(() => {
|
||||||
if (status === "verified") return <StatusPill label="Verified" variant="success" />;
|
if (status === "verified") return <StatusPill label="Verified" variant="success" />;
|
||||||
if (status === "pending") return <StatusPill label="Submitted" variant="info" />;
|
if (status === "pending") return <StatusPill label="Under Review" variant="info" />;
|
||||||
if (status === "rejected") return <StatusPill label="Action needed" variant="warning" />;
|
if (status === "rejected") return <StatusPill label="Action Needed" variant="warning" />;
|
||||||
return <StatusPill label="Not submitted" variant="warning" />;
|
return <StatusPill label="Not Submitted" variant="warning" />;
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const canUpload = status !== "verified";
|
const canUpload = status !== "verified";
|
||||||
@ -48,10 +60,11 @@ export function ResidenceCardVerificationSettingsView() {
|
|||||||
title="ID Verification"
|
title="ID Verification"
|
||||||
description="Upload your residence card for SIM activation"
|
description="Upload your residence card for SIM activation"
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||||||
|
breadcrumbs={[{ label: "Settings", href: "/account/settings" }, { label: "ID Verification" }]}
|
||||||
>
|
>
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
<SubCard
|
<SubCard
|
||||||
title="Residence card"
|
title="Residence Card"
|
||||||
icon={<ShieldCheckIcon className="w-5 h-5 text-primary" />}
|
icon={<ShieldCheckIcon className="w-5 h-5 text-primary" />}
|
||||||
right={statusPill}
|
right={statusPill}
|
||||||
>
|
>
|
||||||
@ -66,53 +79,63 @@ export function ResidenceCardVerificationSettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
) : status === "verified" ? (
|
) : status === "verified" ? (
|
||||||
<AlertBanner variant="success" title="Verified" size="sm" elevated>
|
<div className="space-y-4">
|
||||||
Your residence card is on file and approved. No further action is required.
|
<AlertBanner variant="success" title="Verified" size="sm" elevated>
|
||||||
</AlertBanner>
|
Your residence card is on file and approved. No further action is required.
|
||||||
|
</AlertBanner>
|
||||||
|
{returnTo && (
|
||||||
|
<Button type="button" onClick={() => router.push(returnTo)}>
|
||||||
|
Continue to Checkout
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : status === "pending" ? (
|
) : status === "pending" ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<AlertBanner variant="info" title="Submitted — under review" size="sm" elevated>
|
<AlertBanner variant="info" title="Under review" size="sm" elevated>
|
||||||
We’ll verify your residence card before activating SIM service.
|
We'll verify your residence card before activating SIM service.
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
|
|
||||||
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
|
{(residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt) && (
|
||||||
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
|
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Submitted document
|
Submitted document
|
||||||
</div>
|
</div>
|
||||||
{residenceCardQuery.data?.filename ? (
|
{residenceCardQuery.data?.filename && (
|
||||||
<div className="mt-1 text-sm font-medium text-foreground">
|
<div className="mt-1 text-sm font-medium text-foreground">
|
||||||
{residenceCardQuery.data.filename}
|
{residenceCardQuery.data.filename}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
|
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
|
{returnTo && (
|
||||||
|
<Button type="button" onClick={() => router.push(returnTo)}>
|
||||||
|
Continue to Checkout
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
variant={status === "rejected" ? "warning" : "info"}
|
variant={status === "rejected" ? "warning" : "info"}
|
||||||
title={status === "rejected" ? "Rejected — please resubmit" : "Upload required"}
|
title={status === "rejected" ? "Verification rejected" : "Upload required"}
|
||||||
size="sm"
|
size="sm"
|
||||||
elevated
|
elevated
|
||||||
>
|
>
|
||||||
<div className="space-y-2 text-sm text-foreground/80">
|
<div className="space-y-2 text-sm">
|
||||||
{status === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
|
{status === "rejected" && residenceCardQuery.data?.reviewerNotes && (
|
||||||
<div>
|
<p>{residenceCardQuery.data.reviewerNotes}</p>
|
||||||
<div className="font-medium text-foreground">Rejection note</div>
|
)}
|
||||||
<div>{residenceCardQuery.data.reviewerNotes}</div>
|
<p>Upload a clear photo or scan of your residence card (JPG, PNG, or PDF).</p>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<p>Upload a clear photo/scan of your residence card (JPG, PNG, or PDF).</p>
|
|
||||||
</div>
|
</div>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
|
|
||||||
{canUpload ? (
|
{canUpload && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<input
|
<input
|
||||||
ref={residenceFileInputRef}
|
ref={residenceFileInputRef}
|
||||||
@ -122,7 +145,7 @@ export function ResidenceCardVerificationSettingsView() {
|
|||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{residenceFile ? (
|
{residenceFile && (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-medium text-muted-foreground">
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
@ -146,14 +169,14 @@ export function ResidenceCardVerificationSettingsView() {
|
|||||||
Change
|
Change
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
{returnTo ? (
|
{returnTo && (
|
||||||
<Button type="button" variant="outline" onClick={() => router.push(returnTo)}>
|
<Button type="button" variant="outline" onClick={() => router.push(returnTo)}>
|
||||||
Back to checkout
|
Back to checkout
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!residenceFile || submitResidenceCard.isPending}
|
disabled={!residenceFile || submitResidenceCard.isPending}
|
||||||
@ -167,23 +190,31 @@ export function ResidenceCardVerificationSettingsView() {
|
|||||||
if (residenceFileInputRef.current) {
|
if (residenceFileInputRef.current) {
|
||||||
residenceFileInputRef.current.value = "";
|
residenceFileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
// If there's a returnTo, navigate back after success
|
||||||
|
if (returnTo) {
|
||||||
|
router.push(returnTo);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Submit
|
Submit Document
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submitResidenceCard.isError && (
|
{submitResidenceCard.isError && (
|
||||||
<div className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
{submitResidenceCard.error instanceof Error
|
{submitResidenceCard.error instanceof Error
|
||||||
? submitResidenceCard.error.message
|
? submitResidenceCard.error.message
|
||||||
: "Failed to submit residence card."}
|
: "Failed to submit residence card."}
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Accepted formats: JPG, PNG, or PDF. Make sure all text is readable.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SubCard>
|
</SubCard>
|
||||||
|
|||||||
@ -1,220 +0,0 @@
|
|||||||
# Security Monitoring Setup
|
|
||||||
|
|
||||||
## 🎯 Quick Start
|
|
||||||
|
|
||||||
Your project now has comprehensive security monitoring! Here's what was set up:
|
|
||||||
|
|
||||||
## 📦 What's Included
|
|
||||||
|
|
||||||
### 1. **GitHub Actions Workflows** (`.github/workflows/`)
|
|
||||||
|
|
||||||
#### `security.yml` - Main Security Pipeline
|
|
||||||
|
|
||||||
- **Daily scans** at 9 AM UTC
|
|
||||||
- **Pull request** security checks
|
|
||||||
- **Manual trigger** available
|
|
||||||
- Includes:
|
|
||||||
- Dependency vulnerability audit
|
|
||||||
- Dependency review (for PRs)
|
|
||||||
- CodeQL security analysis
|
|
||||||
- Outdated dependencies check
|
|
||||||
|
|
||||||
#### `pr-checks.yml` - Pull Request Quality Gate
|
|
||||||
|
|
||||||
- Runs on every PR
|
|
||||||
- Checks: linting, type safety, security audit, tests, formatting
|
|
||||||
|
|
||||||
#### `dependency-update.yml` - Auto-merge Helper
|
|
||||||
|
|
||||||
- Auto-approves safe dependency updates
|
|
||||||
- Auto-merges patch updates
|
|
||||||
- Works with Dependabot
|
|
||||||
|
|
||||||
### 2. **Dependabot Configuration** (`.github/dependabot.yml`)
|
|
||||||
|
|
||||||
- **Weekly** dependency updates (Mondays at 9 AM)
|
|
||||||
- Groups updates to reduce PR noise
|
|
||||||
- Monitors: npm, GitHub Actions, Docker
|
|
||||||
- Auto-labels PRs for easy tracking
|
|
||||||
|
|
||||||
### 3. **Git Hooks** (`.husky/`)
|
|
||||||
|
|
||||||
- **pre-commit**: Runs linting and type checks
|
|
||||||
- **pre-push**: Optional security audit (commented out by default)
|
|
||||||
|
|
||||||
### 4. **NPM Scripts** (Enhanced)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm security:audit # Full security audit
|
|
||||||
pnpm security:check # Check high/critical vulnerabilities
|
|
||||||
pnpm security:fix # Auto-fix vulnerabilities when possible
|
|
||||||
pnpm security:report # Generate JSON report
|
|
||||||
pnpm update:check # Check for outdated packages
|
|
||||||
pnpm update:safe # Safe update with verification
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
|
||||||
|
|
||||||
### 1. Fix Current Vulnerability
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update Next.js to fix the current high-severity issue
|
|
||||||
cd /home/barsa/projects/customer_portal/customer-portal
|
|
||||||
pnpm add next@latest --filter @customer-portal/portal
|
|
||||||
pnpm security:check
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Enable GitHub Actions
|
|
||||||
|
|
||||||
- Push these changes to GitHub
|
|
||||||
- Go to **Settings → Actions → General**
|
|
||||||
- Enable **Read and write permissions** for workflows
|
|
||||||
- Go to **Settings → Code security → Dependabot**
|
|
||||||
- Enable **Dependabot alerts** and **security updates**
|
|
||||||
|
|
||||||
### 3. Optional: Enable Stricter Pre-push Checks
|
|
||||||
|
|
||||||
Edit `.husky/pre-push` and uncomment the security check lines to run audits before every push.
|
|
||||||
|
|
||||||
## 📊 Monitoring Dashboard
|
|
||||||
|
|
||||||
### View Security Status
|
|
||||||
|
|
||||||
1. **GitHub Actions**: Check `.github/workflows/security.yml` runs
|
|
||||||
2. **Dependabot**: View PRs in **Pull requests** tab
|
|
||||||
3. **Security Advisories**: Check **Security** tab
|
|
||||||
4. **Artifacts**: Download audit reports from workflow runs
|
|
||||||
|
|
||||||
### Email Notifications
|
|
||||||
|
|
||||||
GitHub will automatically notify you about:
|
|
||||||
|
|
||||||
- Security vulnerabilities
|
|
||||||
- Failed workflow runs
|
|
||||||
- Dependabot PRs
|
|
||||||
|
|
||||||
### Configure Notifications
|
|
||||||
|
|
||||||
1. Go to **Settings → Notifications**
|
|
||||||
2. Enable **Actions** and **Dependabot** notifications
|
|
||||||
3. Choose **Email** or **Web** notifications
|
|
||||||
|
|
||||||
## 🔄 Workflow Triggers
|
|
||||||
|
|
||||||
### Automatic
|
|
||||||
|
|
||||||
- **Daily**: Full security scan at 9 AM UTC
|
|
||||||
- **On Push**: Security checks when pushing to main/master
|
|
||||||
- **On PR**: Comprehensive checks including dependency review
|
|
||||||
- **Weekly**: Dependabot checks for updates (Mondays)
|
|
||||||
|
|
||||||
### Manual
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Trigger from GitHub UI
|
|
||||||
1. Go to Actions → Security Audit
|
|
||||||
2. Click "Run workflow"
|
|
||||||
3. Select branch and run
|
|
||||||
|
|
||||||
# Or use GitHub CLI
|
|
||||||
gh workflow run security.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Local Development
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm lint # Check code quality
|
|
||||||
pnpm type-check # Verify types
|
|
||||||
pnpm security:check # Check vulnerabilities
|
|
||||||
pnpm test # Run tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Weekly Maintenance
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm update:check # See what's outdated
|
|
||||||
pnpm update:safe # Update safely
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generate Security Report
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm security:report
|
|
||||||
# Creates security-report.json with detailed findings
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Best Practices
|
|
||||||
|
|
||||||
### For Daily Development
|
|
||||||
|
|
||||||
- ✅ Run `pnpm security:check` weekly
|
|
||||||
- ✅ Review Dependabot PRs within 48 hours
|
|
||||||
- ✅ Keep dependencies up to date
|
|
||||||
- ✅ Never commit secrets (use `.env` files)
|
|
||||||
|
|
||||||
### For Security Issues
|
|
||||||
|
|
||||||
- 🚨 **High/Critical**: Fix within 24 hours
|
|
||||||
- ⚠️ **Medium**: Fix within 1 week
|
|
||||||
- ℹ️ **Low**: Fix in next maintenance window
|
|
||||||
|
|
||||||
### For Dependency Updates
|
|
||||||
|
|
||||||
- ✅ **Patch versions**: Auto-merge after CI passes
|
|
||||||
- ⚠️ **Minor versions**: Review and test
|
|
||||||
- 🚨 **Major versions**: Careful review and thorough testing
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### If Security Scan Fails
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View detailed audit
|
|
||||||
pnpm audit
|
|
||||||
|
|
||||||
# Try to auto-fix
|
|
||||||
pnpm security:fix
|
|
||||||
|
|
||||||
# If auto-fix doesn't work, update manually
|
|
||||||
pnpm update [package-name]@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### If Workflow Fails
|
|
||||||
|
|
||||||
1. Check workflow logs in GitHub Actions
|
|
||||||
2. Run the same commands locally
|
|
||||||
3. Ensure all secrets are configured
|
|
||||||
4. Verify permissions are set correctly
|
|
||||||
|
|
||||||
## 📚 Additional Resources
|
|
||||||
|
|
||||||
- **Security Policy**: See `SECURITY.md`
|
|
||||||
- **Complete Guide**: See `docs/portal-guides/COMPLETE-GUIDE.md`
|
|
||||||
- **GitHub Security**: [https://docs.github.com/en/code-security](https://docs.github.com/en/code-security)
|
|
||||||
- **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security)
|
|
||||||
|
|
||||||
## 🎉 Next Steps
|
|
||||||
|
|
||||||
1. **Fix the current vulnerability**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add next@16.0.10 --filter @customer-portal/portal
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Push to GitHub** to activate workflows:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: add comprehensive security monitoring"
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Enable Dependabot** in GitHub repository settings
|
|
||||||
|
|
||||||
4. **Review first security scan** in GitHub Actions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Need Help?** Check `SECURITY.md` for detailed security policies and contact information.
|
|
||||||
30
docs/_archive/README.md
Normal file
30
docs/_archive/README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Archived Documentation
|
||||||
|
|
||||||
|
This folder contains historical documentation that is no longer actively maintained but is kept for reference.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
### `/planning/`
|
||||||
|
|
||||||
|
Development planning documents and task checklists from feature development sprints:
|
||||||
|
|
||||||
|
- **public-catalog-tasks.md** - Task checklist for public catalog implementation
|
||||||
|
- **public-catalog-unified-checkout.md** - Development plan for unified checkout feature
|
||||||
|
|
||||||
|
### `/refactoring/`
|
||||||
|
|
||||||
|
Documentation from completed refactoring efforts:
|
||||||
|
|
||||||
|
- **subscriptions-refactor.md** - Summary of subscriptions list refactoring
|
||||||
|
- **clean-architecture-summary.md** - Summary of clean architecture implementation
|
||||||
|
|
||||||
|
### `/reviews/`
|
||||||
|
|
||||||
|
Point-in-time code reviews and analysis documents:
|
||||||
|
|
||||||
|
- **shop-checkout-review.md** - Checkout process review (Dec 2024)
|
||||||
|
- **shop-eligibility-verification-opportunity-flow-review.md** - Flow analysis (Dec 2024)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note:** These documents may contain outdated information. For current system behavior, refer to the active documentation in the parent `docs/` directory.
|
||||||
3
docs/_archive/planning/README.md
Normal file
3
docs/_archive/planning/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Planning Documents
|
||||||
|
|
||||||
|
Historical planning and task tracking documents from feature development.
|
||||||
3
docs/_archive/refactoring/README.md
Normal file
3
docs/_archive/refactoring/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Refactoring Documents
|
||||||
|
|
||||||
|
Documentation from completed refactoring and improvement efforts.
|
||||||
@ -7,41 +7,48 @@ Refactored the subscriptions list page to follow clean architecture principles,
|
|||||||
## Problems Fixed
|
## Problems Fixed
|
||||||
|
|
||||||
### 1. **Business Logic in Frontend** ❌
|
### 1. **Business Logic in Frontend** ❌
|
||||||
|
|
||||||
**Before:**
|
**Before:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Frontend was detecting one-time products by product name
|
// Frontend was detecting one-time products by product name
|
||||||
const getBillingCycle = (subscription: Subscription) => {
|
const getBillingCycle = (subscription: Subscription) => {
|
||||||
const name = subscription.productName.toLowerCase();
|
const name = subscription.productName.toLowerCase();
|
||||||
const looksLikeActivation =
|
const looksLikeActivation =
|
||||||
name.includes("activation fee") ||
|
name.includes("activation fee") || name.includes("activation") || name.includes("setup");
|
||||||
name.includes("activation") ||
|
|
||||||
name.includes("setup");
|
|
||||||
return looksLikeActivation ? "One-time" : subscription.cycle;
|
return looksLikeActivation ? "One-time" : subscription.cycle;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**After:** ✅
|
**After:** ✅
|
||||||
|
|
||||||
- **BFF already sends correct cycle** via WHMCS mapper
|
- **BFF already sends correct cycle** via WHMCS mapper
|
||||||
- Frontend trusts the data from BFF
|
- Frontend trusts the data from BFF
|
||||||
- No business logic to detect product types
|
- No business logic to detect product types
|
||||||
|
|
||||||
### 2. **Inconsistent List Designs** ❌
|
### 2. **Inconsistent List Designs** ❌
|
||||||
|
|
||||||
**Before:**
|
**Before:**
|
||||||
|
|
||||||
- Invoices: Clean `InvoiceTable` component with modern DataTable
|
- Invoices: Clean `InvoiceTable` component with modern DataTable
|
||||||
- Subscriptions: Custom card-based list with manual rendering
|
- Subscriptions: Custom card-based list with manual rendering
|
||||||
- Different patterns for similar functionality
|
- Different patterns for similar functionality
|
||||||
|
|
||||||
**After:** ✅
|
**After:** ✅
|
||||||
|
|
||||||
- Created `SubscriptionTable` component following `InvoiceTable` pattern
|
- Created `SubscriptionTable` component following `InvoiceTable` pattern
|
||||||
- Consistent design across all list pages
|
- Consistent design across all list pages
|
||||||
- Reusable DataTable component
|
- Reusable DataTable component
|
||||||
|
|
||||||
### 3. **No Use of Domain Types/Validation** ❌
|
### 3. **No Use of Domain Types/Validation** ❌
|
||||||
|
|
||||||
**Before:**
|
**Before:**
|
||||||
|
|
||||||
- Frontend had local type transformations
|
- Frontend had local type transformations
|
||||||
- Business logic scattered across components
|
- Business logic scattered across components
|
||||||
|
|
||||||
**After:** ✅
|
**After:** ✅
|
||||||
|
|
||||||
- Uses `Subscription` type from `@customer-portal/domain/subscriptions`
|
- Uses `Subscription` type from `@customer-portal/domain/subscriptions`
|
||||||
- BFF handles all transformations via domain mappers
|
- BFF handles all transformations via domain mappers
|
||||||
- Frontend only displays pre-validated data
|
- Frontend only displays pre-validated data
|
||||||
@ -77,13 +84,13 @@ const getBillingCycle = (subscription: Subscription) => {
|
|||||||
|
|
||||||
### What Goes Where
|
### What Goes Where
|
||||||
|
|
||||||
| Concern | Layer | Example |
|
| Concern | Layer | Example |
|
||||||
|---------|-------|---------|
|
| ----------------------- | -------------------------- | --------------------------------------------- |
|
||||||
| **Data transformation** | BFF | Detect one-time products, map WHMCS status |
|
| **Data transformation** | BFF | Detect one-time products, map WHMCS status |
|
||||||
| **Validation** | Domain | Zod schemas, type safety |
|
| **Validation** | Domain | Zod schemas, type safety |
|
||||||
| **Display formatting** | Frontend or Domain Toolkit | Currency, dates, simple text transforms |
|
| **Display formatting** | Frontend or Domain Toolkit | Currency, dates, simple text transforms |
|
||||||
| **Business rules** | BFF | Pricing, billing cycles, activation detection |
|
| **Business rules** | BFF | Pricing, billing cycles, activation detection |
|
||||||
| **UI State** | Frontend | Search, filters, loading states |
|
| **UI State** | Frontend | Search, filters, loading states |
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
@ -106,6 +113,7 @@ import { Formatting } from "@customer-portal/domain/toolkit";
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Clean table design with hover states
|
- Clean table design with hover states
|
||||||
- Status badges with icons
|
- Status badges with icons
|
||||||
- Proper date formatting using `date-fns`
|
- Proper date formatting using `date-fns`
|
||||||
@ -121,8 +129,10 @@ import { Formatting } from "@customer-portal/domain/toolkit";
|
|||||||
// Simple UI text transformation (OK in frontend)
|
// Simple UI text transformation (OK in frontend)
|
||||||
const getBillingPeriodText = (cycle: string): string => {
|
const getBillingPeriodText = (cycle: string): string => {
|
||||||
switch (cycle) {
|
switch (cycle) {
|
||||||
case "Monthly": return "per month";
|
case "Monthly":
|
||||||
case "Annually": return "per year";
|
return "per month";
|
||||||
|
case "Annually":
|
||||||
|
return "per year";
|
||||||
// ... etc
|
// ... etc
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -139,21 +149,25 @@ const getBillingPeriodText = (cycle: string): string => {
|
|||||||
## Benefits
|
## Benefits
|
||||||
|
|
||||||
### ✅ Clean Architecture
|
### ✅ Clean Architecture
|
||||||
|
|
||||||
- Business logic stays in BFF where it belongs
|
- Business logic stays in BFF where it belongs
|
||||||
- Frontend is a thin presentation layer
|
- Frontend is a thin presentation layer
|
||||||
- Domain provides shared types and validation
|
- Domain provides shared types and validation
|
||||||
|
|
||||||
### ✅ Consistency
|
### ✅ Consistency
|
||||||
|
|
||||||
- All list pages use the same pattern
|
- All list pages use the same pattern
|
||||||
- Predictable structure for developers
|
- Predictable structure for developers
|
||||||
- Easier to maintain and extend
|
- Easier to maintain and extend
|
||||||
|
|
||||||
### ✅ Type Safety
|
### ✅ Type Safety
|
||||||
|
|
||||||
- Uses domain types everywhere
|
- Uses domain types everywhere
|
||||||
- TypeScript enforces contracts
|
- TypeScript enforces contracts
|
||||||
- Zod validation at runtime
|
- Zod validation at runtime
|
||||||
|
|
||||||
### ✅ Better UX
|
### ✅ Better UX
|
||||||
|
|
||||||
- Modern, clean design
|
- Modern, clean design
|
||||||
- Smooth hover effects
|
- Smooth hover effects
|
||||||
- Consistent with invoice list
|
- Consistent with invoice list
|
||||||
@ -162,13 +176,16 @@ const getBillingPeriodText = (cycle: string): string => {
|
|||||||
## Files Changed
|
## Files Changed
|
||||||
|
|
||||||
### Created
|
### Created
|
||||||
|
|
||||||
- `apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx`
|
- `apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx`
|
||||||
- `apps/portal/src/features/subscriptions/components/SubscriptionTable/index.ts`
|
- `apps/portal/src/features/subscriptions/components/SubscriptionTable/index.ts`
|
||||||
|
|
||||||
### Modified
|
### Modified
|
||||||
|
|
||||||
- `apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx` - Simplified, removed business logic
|
- `apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx` - Simplified, removed business logic
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Unnecessary display helpers (business logic doesn't belong in domain)
|
- Unnecessary display helpers (business logic doesn't belong in domain)
|
||||||
|
|
||||||
## Lessons Learned
|
## Lessons Learned
|
||||||
@ -182,8 +199,8 @@ const getBillingPeriodText = (cycle: string): string => {
|
|||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
Consider applying this pattern to other list pages:
|
Consider applying this pattern to other list pages:
|
||||||
|
|
||||||
- Orders list
|
- Orders list
|
||||||
- Payment methods list
|
- Payment methods list
|
||||||
- Support tickets list
|
- Support tickets list
|
||||||
- etc.
|
- etc.
|
||||||
|
|
||||||
3
docs/_archive/reviews/README.md
Normal file
3
docs/_archive/reviews/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Code Reviews
|
||||||
|
|
||||||
|
Point-in-time code review and analysis documents.
|
||||||
@ -1,6 +1,7 @@
|
|||||||
# Customer Portal Flow Review: Shop, Eligibility, ID Verification & Opportunity Management
|
# Customer Portal Flow Review: Shop, Eligibility, ID Verification & Opportunity Management
|
||||||
|
|
||||||
**Review Date:** December 23, 2025
|
**Review Date:** December 23, 2025
|
||||||
|
**Last Updated:** December 23, 2025
|
||||||
**Scope:** Complete end-to-end analysis of customer acquisition flows
|
**Scope:** Complete end-to-end analysis of customer acquisition flows
|
||||||
**Priority Focus:** Customer Experience (CX)
|
**Priority Focus:** Customer Experience (CX)
|
||||||
|
|
||||||
@ -24,25 +25,28 @@
|
|||||||
|
|
||||||
### Current State Assessment
|
### Current State Assessment
|
||||||
|
|
||||||
| Area | Status | CX Impact | Notes |
|
| Area | Status | CX Impact | Notes |
|
||||||
| ---------------------- | ------------- | ------------- | --------------------------------- |
|
| ---------------------- | -------- | --------- | ---------------------------------------- |
|
||||||
| Shop/Catalog | ✅ Good | Low Risk | Well-structured, cached |
|
| Shop/Catalog | ✅ Good | Low Risk | Well-structured, cached |
|
||||||
| Internet Eligibility | ⚠️ Needs Work | **High Risk** | Manual process, no SLA visibility |
|
| Internet Eligibility | ✅ Good | Low Risk | Manual NTT check, email notifications |
|
||||||
| ID Verification | ⚠️ Needs Work | **High Risk** | Manual review path unclear |
|
| ID Verification | ✅ Good | Low Risk | Integrated into Profile page |
|
||||||
| Opportunity Management | ⚠️ Needs Work | Medium Risk | Some fields not created in SF |
|
| Opportunity Management | ✅ Good | Low Risk | Fields exist, WHMCS linking works |
|
||||||
| Order Fulfillment | ✅ Good | Low Risk | Distributed transaction support |
|
| Order Fulfillment | ✅ Good | Low Risk | Distributed transaction support |
|
||||||
|
| Profile Data | ✅ Fixed | Low Risk | Customer number, DOB, gender now display |
|
||||||
|
|
||||||
### Key Findings
|
### Key Findings
|
||||||
|
|
||||||
1. **Internet Eligibility is a Major CX Bottleneck** - Customers requesting eligibility have no visibility into when they'll get a response. The flow is entirely manual with no SLA tracking.
|
1. **Internet Subscription Detection** - Now matches "SonixNet via NTT Optical Fiber" products in addition to "Internet" named products.
|
||||||
|
|
||||||
2. **ID Verification Status Communication is Weak** - After uploading documents, customers see "pending" but have no timeline or next steps.
|
2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page.
|
||||||
|
|
||||||
3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are already in place.
|
3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working.
|
||||||
|
|
||||||
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but currently not ID verification (potential gap).
|
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification.
|
||||||
|
|
||||||
5. **Error Handling is Production-Ready** - Rollback mechanisms and error messages follow best practices [[memory:6689308]].
|
5. **Opportunity ↔ WHMCS Linking** - After provisioning, Opportunity is linked via `WHMCS_Service_ID__c` to enable cancellation workflows.
|
||||||
|
|
||||||
|
6. **Cancellation is NOT Automated to WHMCS** - Portal updates Salesforce Opportunity and creates Case, but WHMCS service termination requires agent action.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1212,22 +1216,9 @@ enum NotificationSource {
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
This implementation provides a solid foundation for customer acquisition flows:
|
This implementation provides a complete foundation for customer acquisition and service management flows.
|
||||||
|
|
||||||
**✅ Working Well:**
|
### All Features Working
|
||||||
|
|
||||||
1. **Salesforce integration** - Fields exist, emails configured via Flows
|
|
||||||
2. **Error handling** - Production-ready, no sensitive data exposure
|
|
||||||
3. **Distributed transactions** - Fulfillment with rollback support
|
|
||||||
4. **Caching** - Catalog and eligibility data cached appropriately
|
|
||||||
5. **In-app notifications** - Implemented with CDC integration
|
|
||||||
|
|
||||||
**⚠️ Still Needs Improvement:**
|
|
||||||
|
|
||||||
1. ~~**Eligibility flow** needs timeline visibility in UI~~ → ✅ Added timeline messaging
|
|
||||||
2. **ID verification** needs structured rejection reasons
|
|
||||||
|
|
||||||
### Implemented Features
|
|
||||||
|
|
||||||
| Feature | Status | Location |
|
| Feature | Status | Location |
|
||||||
| ----------------------------- | ------- | ---------------------------------------------------------------- |
|
| ----------------------------- | ------- | ---------------------------------------------------------------- |
|
||||||
@ -1243,22 +1234,41 @@ This implementation provides a solid foundation for customer acquisition flows:
|
|||||||
| Guest Checkout Removal | ✅ Done | Removed `checkout-registration` module, redirect to login |
|
| Guest Checkout Removal | ✅ Done | Removed `checkout-registration` module, redirect to login |
|
||||||
| Checkout Store Simplification | ✅ Done | `apps/portal/src/features/checkout/stores/checkout.store.ts` |
|
| Checkout Store Simplification | ✅ Done | `apps/portal/src/features/checkout/stores/checkout.store.ts` |
|
||||||
| OrderType Standardization | ✅ Done | PascalCase ("Internet", "SIM", "VPN") across all layers |
|
| OrderType Standardization | ✅ Done | PascalCase ("Internet", "SIM", "VPN") across all layers |
|
||||||
|
| Internet Subscription Match | ✅ Done | Matches "SonixNet via NTT Optical Fiber" products |
|
||||||
|
| ID Verification in Profile | ✅ Done | `apps/portal/src/features/account/views/ProfileContainer.tsx` |
|
||||||
|
| Profile Data Display | ✅ Done | Customer number, DOB, gender now load correctly |
|
||||||
|
| Opportunity ↔ WHMCS Linking | ✅ Done | `WHMCS_Service_ID__c` field links after provisioning |
|
||||||
|
|
||||||
### Remaining Priority Actions
|
### Key Architecture Points
|
||||||
|
|
||||||
1. ✅ **Resolved:** Salesforce fields verified (`Opportunity_Source__c`, `WHMCS_Service_ID__c`)
|
1. **Subscription Type Detection** - Uses product name matching:
|
||||||
2. ✅ **Resolved:** Email notifications handled by Salesforce Flows
|
- Internet: matches "internet", "sonixnet", or "ntt" + "fiber"
|
||||||
3. ✅ **Implemented:** In-app notification system with Platform Events
|
- SIM: matches "sim" in product/group name
|
||||||
4. ✅ **Implemented:** Timeline messaging in eligibility UI ("Usually 1-2 business days")
|
|
||||||
5. 📅 **This Week:** Create SF email templates (see Salesforce Email Configuration section)
|
|
||||||
6. 📅 **This Month:** Add structured rejection reasons with remediation steps
|
|
||||||
7. 📅 **This Quarter:** Explore NTT API automation for instant eligibility
|
|
||||||
|
|
||||||
### Database Migration Required
|
2. **ID Verification** - Integrated into Profile page (`/account/settings`), standalone page kept for checkout redirects
|
||||||
|
|
||||||
Run the following to apply the notification schema:
|
3. **Opportunity ↔ WHMCS Link** - `WHMCS_Service_ID__c` stores WHMCS service ID after provisioning
|
||||||
|
|
||||||
|
4. **Cancellation Flow** - Portal updates Salesforce, WHMCS termination is manual (agent action)
|
||||||
|
|
||||||
|
5. **Profile Data** - Fetched from WHMCS custom fields (IDs: 198, 200, 201 by default)
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
**WHMCS Cancellation is NOT Automated:**
|
||||||
|
When a customer requests cancellation, the portal:
|
||||||
|
|
||||||
|
- Updates Salesforce Opportunity (stage, scheduled date, etc.)
|
||||||
|
- Creates Salesforce Case for agent
|
||||||
|
- Does NOT automatically terminate WHMCS service
|
||||||
|
|
||||||
|
Agent must manually terminate WHMCS service on the scheduled date.
|
||||||
|
|
||||||
|
**Profile Data Field IDs:**
|
||||||
|
If customer number, DOB, or gender don't display, verify these env variables match your WHMCS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/bff
|
WHMCS_CUSTOMER_NUMBER_FIELD_ID=198
|
||||||
npx prisma migrate dev --name add_notifications
|
WHMCS_DOB_FIELD_ID=201
|
||||||
|
WHMCS_GENDER_FIELD_ID=200
|
||||||
```
|
```
|
||||||
Binary file not shown.
@ -2,15 +2,24 @@
|
|||||||
|
|
||||||
These guides explain what the portal does, how data moves between WHMCS and Salesforce, and where caching affects what people see. They avoid deep implementation detail and focus on flows, data ownership, and error handling.
|
These guides explain what the portal does, how data moves between WHMCS and Salesforce, and where caching affects what people see. They avoid deep implementation detail and focus on flows, data ownership, and error handling.
|
||||||
|
|
||||||
Start with `system-overview.md`, then jump into the feature you care about:
|
Start with `system-overview.md`, then jump into the feature you care about.
|
||||||
|
|
||||||
- `COMPLETE-GUIDE.md` — single, end-to-end explanation of how the portal works
|
## Guides
|
||||||
- `system-overview.md` — high-level architecture, data ownership, and caching quick reference.
|
|
||||||
- `accounts-and-identity.md` — how sign-up, WHMCS linking, and address/profile updates behave.
|
| Guide | Description |
|
||||||
- `catalog-and-checkout.md` — where products/pricing come from and what we check before checkout.
|
| --------------------------------------------------------------- | ------------------------------------------------------ |
|
||||||
- `eligibility-and-verification.md` — internet eligibility + SIM ID verification (Salesforce-driven).
|
| [Complete Guide](./COMPLETE-GUIDE.md) | Single, end-to-end explanation of how the portal works |
|
||||||
- `orders-and-provisioning.md` — order lifecycle in Salesforce and how it is fulfilled into WHMCS.
|
| [System Overview](./system-overview.md) | High-level architecture, data ownership, and caching |
|
||||||
- `billing-and-payments.md` — invoices, payment methods, and how billing links are handled.
|
| [Accounts & Identity](./accounts-and-identity.md) | Sign-up, WHMCS linking, and address/profile updates |
|
||||||
- `subscriptions.md` — how active services are read and refreshed.
|
| [Catalog & Checkout](./catalog-and-checkout.md) | Product source, pricing, and checkout flow |
|
||||||
- `support-cases.md` — how cases are created/read in Salesforce from the portal.
|
| [Eligibility & Verification](./eligibility-and-verification.md) | Internet eligibility + SIM ID verification |
|
||||||
- `ui-design-system.md` — UI tokens, page shells, and component patterns to keep pages consistent.
|
| [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment |
|
||||||
|
| [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links |
|
||||||
|
| [Subscriptions](./subscriptions.md) | How active services are read and refreshed |
|
||||||
|
| [Support Cases](./support-cases.md) | Case creation/reading in Salesforce |
|
||||||
|
| [UI Design System](./ui-design-system.md) | UI tokens, page shells, component patterns |
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Salesforce Requirements](../salesforce/SALESFORCE-REQUIREMENTS.md) – Required fields, flows, and setup
|
||||||
|
- [Architecture Overview](../architecture/ARCHITECTURE.md) – System design
|
||||||
|
|||||||
@ -1,228 +1,201 @@
|
|||||||
# Eligibility & Verification (Salesforce-Driven)
|
# Eligibility & Verification
|
||||||
|
|
||||||
This guide describes the intended “Salesforce is the source of truth” model for:
|
This guide describes how eligibility and verification work in the customer portal:
|
||||||
|
|
||||||
- **Internet eligibility** (address/serviceability review)
|
- **Internet eligibility** (NTT serviceability review)
|
||||||
- **SIM ID verification** (residence card / identity document)
|
- **SIM ID verification** (residence card / identity document)
|
||||||
|
|
||||||
It also explains how these checks gate checkout and where the portal should display their status.
|
## Overview
|
||||||
|
|
||||||
## Goals
|
| Concept | Source of Truth | Description |
|
||||||
|
|
||||||
- Make eligibility/verification **account-level** so repeat orders are frictionless.
|
|
||||||
- Use **Salesforce workflow + audit trail** (Cases + Account fields + Files) instead of portal-only tables.
|
|
||||||
- Keep checkout clean: show **one canonical status** and a single next action.
|
|
||||||
|
|
||||||
## Concepts & Ownership
|
|
||||||
|
|
||||||
| Concept | Source of truth | Why |
|
|
||||||
| --------------------------- | ------------------------------------------- | -------------------------------- |
|
| --------------------------- | ------------------------------------------- | -------------------------------- |
|
||||||
| Products + pricing | Salesforce pricebook | Single catalog truth |
|
| Products + pricing | Salesforce pricebook | Single catalog truth |
|
||||||
| Payment methods | WHMCS | No card storage in portal |
|
| Payment methods | WHMCS | Card storage via Stripe |
|
||||||
| Orders + fulfillment | Salesforce Order (and downstream WHMCS) | Operational workflow |
|
| Orders + fulfillment | Salesforce Order (and downstream WHMCS) | Operational workflow |
|
||||||
| Internet eligibility status | Salesforce Account (with Case for workflow) | Reuse for future internet orders |
|
| Internet eligibility status | Salesforce Account (with Case for workflow) | Reuse for future internet orders |
|
||||||
| SIM ID verification status | Salesforce Account (with Files) | Reuse for future SIM orders |
|
| SIM ID verification status | Salesforce Account (with Files) | Reuse for future SIM orders |
|
||||||
|
|
||||||
## Internet Eligibility (Address Review)
|
## Internet Eligibility (NTT Address Review)
|
||||||
|
|
||||||
### Target UX
|
### How It Works
|
||||||
|
|
||||||
- On `/account/shop/internet`:
|
1. Customer navigates to `/account/shop/internet`
|
||||||
- Show current eligibility status (e.g. “Checking”, “Eligible for …”, “Not available”, “Action needed”).
|
2. Customer enters service address and requests eligibility check
|
||||||
- If not requested yet: show a single CTA (“Request eligibility review”).
|
3. Portal creates a Salesforce Case for agent review
|
||||||
- In checkout (Internet orders):
|
4. Agent performs NTT serviceability check (manual process)
|
||||||
- If eligibility is **Pending/Not Requested**, the submit CTA is disabled and we guide the user to the next action.
|
5. Agent updates Account eligibility fields
|
||||||
- If **Eligible**, proceed normally.
|
6. Salesforce Flow sends email notification to customer
|
||||||
|
7. Customer returns and sees eligible plans
|
||||||
|
|
||||||
### Target Salesforce Model
|
### Subscription Type Detection
|
||||||
|
|
||||||
**Account fields (canonical, cached by portal):**
|
The portal identifies Internet subscriptions by product name matching:
|
||||||
|
|
||||||
- `Internet Eligibility Status` (picklist)
|
```typescript
|
||||||
- Suggested values:
|
// Matches any of these patterns (case-insensitive):
|
||||||
- `Not Requested` (no check requested yet; address may be missing or unconfirmed)
|
// - "internet"
|
||||||
- `Pending` (case open, awaiting review)
|
// - "sonixnet"
|
||||||
- `Eligible` (approved)
|
// - "ntt" + "fiber"
|
||||||
- `Ineligible` (rejected)
|
const isInternetService =
|
||||||
- `Internet Eligibility` (text/picklist; optional result)
|
productName.includes("internet") ||
|
||||||
- Example: `Home`, `Apartment`, or a more structured code that maps to portal offerings.
|
productName.includes("sonixnet") ||
|
||||||
- Recommended supporting fields (optional but strongly recommended):
|
(productName.includes("ntt") && productName.includes("fiber"));
|
||||||
- `Internet Eligibility Request Date Time` (datetime)
|
```
|
||||||
- Set by the portal/BFF when the customer requests an eligibility check.
|
|
||||||
- Useful for UX (“Requested on …”) and for internal SLAs/reporting.
|
|
||||||
- `Internet Eligibility Checked Date Time` (datetime)
|
|
||||||
- Updated by Salesforce automation when the review completes (approved or rejected).
|
|
||||||
- `Internet Eligibility_Notes` (long text)
|
|
||||||
- `Internet Eligibility_Case_Id` (text / lookup, if you want fast linking to the case from the portal)
|
|
||||||
|
|
||||||
**Case (workflow + audit trail):**
|
### Salesforce Account Fields
|
||||||
|
|
||||||
- Record type: “Internet Eligibility”
|
| Field API Name | Type | Set By | When |
|
||||||
- Fields populated from portal:
|
| ------------------------------------------- | -------- | ------------ | ------------- |
|
||||||
- Account, contact, service address snapshot, desired product (SKU), notes
|
| `Internet_Eligibility_Status__c` | Picklist | Portal/Agent | Request/Check |
|
||||||
- SLA/review handled by internal team; a Flow/Trigger updates Account fields above.
|
| `Internet_Eligibility__c` | Text | Agent | After check |
|
||||||
|
| `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request |
|
||||||
|
| `Internet_Eligibility_Checked_Date_Time__c` | DateTime | Agent | After check |
|
||||||
|
| `Internet_Eligibility_Notes__c` | Text | Agent | After check |
|
||||||
|
| `Internet_Eligibility_Case_Id__c` | Lookup | Portal | On request |
|
||||||
|
|
||||||
### Portal/BFF Flow (proposed)
|
### Status Values
|
||||||
|
|
||||||
1. Portal calls `POST /api/eligibility/internet/request` (or reuse existing hook behavior).
|
| Status | Shop Page UI | Checkout Gating |
|
||||||
2. BFF validates:
|
| --------------- | --------------------------------------- | --------------- |
|
||||||
- account has a service address (or includes the address in request)
|
| `Not Requested` | Show "Request eligibility check" button | Block submit |
|
||||||
- throttling/rate limits
|
| `Pending` | Show "Review in progress" | Block submit |
|
||||||
3. BFF creates Salesforce Case and sets `Internet Eligibility Status = Pending`.
|
| `Eligible` | Show eligible plans | Allow submit |
|
||||||
- Also sets `Internet Eligibility Request Date Time = now()` (first request timestamp).
|
| `Ineligible` | Show "Not available" + contact support | Block submit |
|
||||||
4. Portal reads `GET /api/eligibility/internet` and shows:
|
|
||||||
- `Pending` → “Review in progress”
|
|
||||||
- `Eligible` → “Eligible for: {Internet Eligibility}”
|
|
||||||
- `INEligible` → “Not available” + next steps (support/contact)
|
|
||||||
5. When Salesforce updates the Account fields:
|
|
||||||
- Portal cache invalidates via CDC/eventing (preferred), or via polling fallback.
|
|
||||||
|
|
||||||
### Recommended status → UI mapping
|
## SIM ID Verification (Residence Card)
|
||||||
|
|
||||||
| Status | Shop page | Checkout gating |
|
### How It Works
|
||||||
| --------------- | ------------------------------------------------ | --------------- |
|
|
||||||
| `Not Requested` | Show “Add/confirm address” then “Request review” | Block submit |
|
|
||||||
| `Pending` | Show “Review in progress” | Block submit |
|
|
||||||
| `Eligible` | Show “Eligible for: {Internet Eligibility}” | Allow submit |
|
|
||||||
| `INEligible` | Show “Not available” + support CTA | Block submit |
|
|
||||||
|
|
||||||
### Notes on “Not Requested” vs “Pending”
|
1. Customer navigates to `/account/settings` (Profile page)
|
||||||
|
2. Customer uploads residence card in the "Identity Verification" section
|
||||||
|
3. File is uploaded to Salesforce (ContentVersion linked to Account)
|
||||||
|
4. Agent reviews document and updates verification status
|
||||||
|
5. Customer sees "Verified" status and can order SIM
|
||||||
|
|
||||||
- Use `Not Requested` when the customer has never requested a check (or their address is missing).
|
### Where to Upload
|
||||||
- Use `Pending` immediately after creating the Salesforce Case.
|
|
||||||
- The portal should treat both as “blocked for ordering” but with different next actions:
|
|
||||||
- `Not Requested`: show CTA to request review (and/or prompt to add address).
|
|
||||||
- `Pending`: show status only (no repeated CTA spam), plus optional “View case” link if you expose it.
|
|
||||||
|
|
||||||
Recommended UI details:
|
ID verification is available in two places:
|
||||||
|
|
||||||
- If `Internet Eligibility Request Date Time` is present, show “Requested on {date}”.
|
1. **Profile Page** (`/account/settings`) - Integrated upload in the "Identity Verification" card
|
||||||
- If `Internet Eligibility Checked Date Time` is present, show “Last checked on {date}”.
|
2. **Standalone Page** (`/account/settings/verification`) - For checkout flow redirects
|
||||||
|
|
||||||
## SIM ID Verification (Residence Card / Identity Document)
|
The Profile page is the primary location. The standalone page is used when redirecting from checkout with a `returnTo` parameter.
|
||||||
|
|
||||||
### Target UX
|
### Salesforce Account Fields
|
||||||
|
|
||||||
- In SIM checkout (and any future SIM order flow):
|
| Field API Name | Type | Set By | When |
|
||||||
- If status is `Verified`: show “Verified” and **no upload/change UI**.
|
| ---------------------------------------- | -------- | ------------ | ------------- |
|
||||||
- If `Submitted`: show what was submitted (filename + submitted time) and optionally allow “Replace file”.
|
| `Id_Verification_Status__c` | Picklist | Portal/Agent | Upload/Review |
|
||||||
- If `Not Submitted`: require upload before order submission.
|
| `Id_Verification_Submitted_Date_Time__c` | DateTime | Portal | On upload |
|
||||||
- If `Rejected`: show rejection message and require resubmission.
|
| `Id_Verification_Verified_Date_Time__c` | DateTime | Agent | After review |
|
||||||
- In order detail pages:
|
| `Id_Verification_Note__c` | Text | Agent | After review |
|
||||||
- Show a simple “ID verification: Not submitted / Submitted / Verified” row.
|
| `Id_Verification_Rejection_Message__c` | Text | Agent | If rejected |
|
||||||
|
|
||||||
### Target Salesforce Model
|
### Status Values
|
||||||
|
|
||||||
**Account fields (canonical):**
|
| Status | Portal UI | Can Order SIM? |
|
||||||
|
| --------------- | --------------------------------------- | -------------: |
|
||||||
|
| `Not Submitted` | Show upload form | No |
|
||||||
|
| `Submitted` | Show "Under Review" with submitted info | Yes |
|
||||||
|
| `Verified` | Show "Verified" badge | Yes |
|
||||||
|
| `Rejected` | Show rejection reason + upload form | No |
|
||||||
|
|
||||||
- `Id Verification Status` (picklist)
|
### Supported File Types
|
||||||
- Values:
|
|
||||||
- `Not Submitted`
|
|
||||||
- `Submitted`
|
|
||||||
- `Verified`
|
|
||||||
- `Rejected`
|
|
||||||
- `Id Verification Submitted Date Time` (datetime; optional)
|
|
||||||
- `Id Verification Verified Date Time` (datetime; optional)
|
|
||||||
- `Id Verification Note` (long text; optional)
|
|
||||||
- `Id Verification Rejection Message` (long text; optional)
|
|
||||||
|
|
||||||
**Files (document storage):**
|
- PDF
|
||||||
|
- PNG
|
||||||
|
- JPG/JPEG
|
||||||
|
|
||||||
- Upload as Salesforce Files (ContentVersion)
|
## Portal UI Locations
|
||||||
- Link to Account (ContentDocumentLink)
|
|
||||||
- Optionally keep a reference field on Account (e.g. `IdVerificationContentDocumentId__c`) for fast lookup.
|
|
||||||
|
|
||||||
### Portal/BFF Flow (proposed)
|
| Location | What's Shown |
|
||||||
|
| ------------------------ | ----------------------------------------------- |
|
||||||
|
| `/account/settings` | Profile, Address, ID Verification (with upload) |
|
||||||
|
| `/account/shop/internet` | Eligibility status and eligible plans |
|
||||||
|
| Subscription detail | Service-specific actions (cancel, etc.) |
|
||||||
|
|
||||||
1. Portal calls `GET /api/verification/id` to read the Account’s canonical status.
|
## Cancellation Flow
|
||||||
2. If user uploads a file:
|
|
||||||
- Portal calls `POST /api/verification/id` with multipart file.
|
|
||||||
- BFF uploads File to Salesforce Account and sets:
|
|
||||||
- `Id Verification Status = Submitted`
|
|
||||||
- `Id Verification Submitted Date Time = now()`
|
|
||||||
3. Internal review updates status to `Verified` (and sets verified timestamp/notes).
|
|
||||||
4. Portal displays:
|
|
||||||
- `Verified` → no edit, no upload
|
|
||||||
- `Submitted` → show file metadata + optional replace
|
|
||||||
- `Not Submitted` → upload required before SIM activation
|
|
||||||
- `Rejected` → show rejection message and require resubmission
|
|
||||||
|
|
||||||
### Order-first review model (recommended)
|
### Internet Cancellation
|
||||||
|
|
||||||
For operational convenience, it can be better to review ID **from the specific SIM order**, then roll that result up to the Account so future SIM orders are frictionless.
|
1. Customer navigates to subscription detail → clicks "Request Cancellation"
|
||||||
|
2. Portal creates Salesforce Case with WHMCS Service ID
|
||||||
|
3. Portal updates Opportunity with cancellation data:
|
||||||
|
- `ScheduledCancellationDateAndTime__c` = end of cancellation month
|
||||||
|
- `CancellationNotice__c` = "有" (received)
|
||||||
|
- `LineReturn__c` = "NotYet"
|
||||||
|
4. Agent processes Case:
|
||||||
|
- Terminates WHMCS service on scheduled date
|
||||||
|
- Updates `LineReturn__c` for equipment return tracking
|
||||||
|
- Updates Opportunity to "〇Cancelled"
|
||||||
|
|
||||||
**How it works:**
|
**Note:** WHMCS service termination is manual (agent work). The portal updates Salesforce, but does not automatically terminate in WHMCS.
|
||||||
|
|
||||||
1. Customer uploads the ID document during SIM checkout.
|
### SIM Cancellation
|
||||||
2. BFF attaches the file to the **Salesforce Order** (or an Order-related Case) as the review target.
|
|
||||||
3. BFF sets Account `Id Verification Status = Submitted` immediately (so the portal can show progress).
|
|
||||||
4. A Salesforce Flow (or automation) is triggered when the order’s “ID review” is approved:
|
|
||||||
- sets Account `Id Verification Status = Verified`
|
|
||||||
- sets `Id Verification Verified Date Time`
|
|
||||||
- links (or copies) the approved File to the Account (so it’s “on file” for future orders)
|
|
||||||
- optionally writes a note to `Id Verification Note`
|
|
||||||
|
|
||||||
**Portal implications:**
|
1. Customer navigates to subscription detail → SIM Management → Cancel
|
||||||
|
2. Portal calls Freebit PA02-04 cancellation API
|
||||||
|
3. Service is scheduled for cancellation at end of selected month
|
||||||
|
|
||||||
- Current order detail page can show: “ID verification: Submitted/Verified” sourced from the Account, plus (optionally) a link to the specific file attached to the Order.
|
## Opportunity ↔ WHMCS Linking
|
||||||
- SIM checkout becomes very simple:
|
|
||||||
- `Not Submitted`: upload required
|
|
||||||
- `Submitted`: allow order submission, show “Submitted”
|
|
||||||
- `Verified`: no upload UI, no “change” UI
|
|
||||||
|
|
||||||
### Rejections + resubmission (recommended UX)
|
### How They're Connected
|
||||||
|
|
||||||
Even if you keep a 3-state Account field, the portal can still display “rejected” as a _derived_ UI state using notes/timestamps.
|
After order provisioning, the Opportunity is linked to WHMCS via:
|
||||||
|
|
||||||
Recommended approach:
|
```
|
||||||
|
Opportunity.WHMCS_Service_ID__c = WHMCS Service ID (e.g., 456)
|
||||||
|
```
|
||||||
|
|
||||||
- Use `Id Verification Note` and `Id Verification Rejection Message` for reviewer feedback.
|
### Provisioning Flow
|
||||||
- When a document is not acceptable, set:
|
|
||||||
- `Id Verification Status = Rejected` (so the portal blocks future SIM submissions until a new file is provided)
|
|
||||||
- `Id Verification Rejection Message` = rejection reason (“image too blurry”, “expired”, etc.)
|
|
||||||
|
|
||||||
Portal UI behavior:
|
1. Order placed → Opportunity created (Stage = "Post Processing")
|
||||||
|
2. Order approved → Fulfillment runs
|
||||||
|
3. WHMCS `AddOrder` API called → returns `serviceIds`
|
||||||
|
4. Opportunity updated:
|
||||||
|
- `WHMCS_Service_ID__c` = service ID from WHMCS
|
||||||
|
- `StageName` = "Active"
|
||||||
|
|
||||||
- If `Id Verification Status = Rejected` and `Id Verification Rejection Message` is non-empty:
|
### Finding Opportunity for Cancellation
|
||||||
- show “ID verification rejected” + “Rejection note”
|
|
||||||
- show an “Id Verification Rejection Message” block that tells the customer what to do next
|
|
||||||
- Example content:
|
|
||||||
- “Your ID verification was rejected. Please upload a new, clear photo/scan of your residence card.”
|
|
||||||
- “Make sure all corners are visible, the text is readable, and the document is not expired.”
|
|
||||||
- “After you resubmit, review will restart.”
|
|
||||||
- When the user uploads again:
|
|
||||||
- overwrite the prior file (or create a new version) and set status back to `Submitted`
|
|
||||||
- clear/replace the notes so the UI doesn’t keep showing stale rejection reasons
|
|
||||||
|
|
||||||
### Gating rules (SIM checkout)
|
When customer requests cancellation, the portal:
|
||||||
|
|
||||||
| Status | Can submit order? | What portal shows |
|
1. Takes WHMCS subscription ID from the portal
|
||||||
| --------------- | ----------------: | ----------------------------------------- |
|
2. Queries Salesforce: `WHERE WHMCS_Service_ID__c = {subscriptionId}`
|
||||||
| `Not Submitted` | No | Upload required |
|
3. Updates the found Opportunity with cancellation data
|
||||||
| `Submitted` | Yes | Submitted summary + (optional) replace |
|
|
||||||
| `Verified` | Yes | Verified badge only |
|
|
||||||
| `Rejected` | No | Rejection message + resubmission required |
|
|
||||||
|
|
||||||
## Where to show status (recommended)
|
### If Opportunity is Cancelled but WHMCS is Not
|
||||||
|
|
||||||
- **Shop pages**
|
This can happen if the agent doesn't complete the WHMCS termination. Result:
|
||||||
- Internet: eligibility banner/status on `/account/shop/internet`
|
|
||||||
- SIM: verification requirement banner/status on `/account/shop/sim` (optional)
|
|
||||||
- **Checkout**
|
|
||||||
- Show the relevant status inline near the confirm/requirements cards.
|
|
||||||
- **Orders**
|
|
||||||
- Show “Eligibility / ID verification” status on the order detail page so users can track progress after submitting.
|
|
||||||
- **Dashboard**
|
|
||||||
- Task tiles like “Internet availability review in progress” or “Submit ID to activate SIM”.
|
|
||||||
|
|
||||||
## Notes on transitioning from current implementation
|
- Customer continues to be billed (WHMCS active)
|
||||||
|
- Service remains active (not terminated)
|
||||||
|
- Salesforce says cancelled, WHMCS says active
|
||||||
|
|
||||||
Current portal code uses a portal-side `residence_card_submissions` table for the uploaded file and status. The target model moves canonical status to Salesforce Account fields and stores the file in Salesforce Files.
|
**Prevention:** Agent must follow Case instructions to terminate WHMCS service on the scheduled date.
|
||||||
|
|
||||||
Recommended migration approach:
|
## Customer Profile Data
|
||||||
|
|
||||||
1. Add Salesforce Account fields for eligibility and ID verification.
|
### Data Sources
|
||||||
2. Dual-write (temporary): when portal receives an upload, store in both DB and Salesforce.
|
|
||||||
3. Switch reads to Salesforce status.
|
| Field | Source | Editable in Portal? |
|
||||||
4. Backfill existing DB submissions into Salesforce Files.
|
| --------------- | ---------------------------- | ------------------- |
|
||||||
5. Remove DB storage once operationally safe.
|
| Email | Portal DB / WHMCS | Yes |
|
||||||
|
| Phone Number | WHMCS | Yes |
|
||||||
|
| First Name | WHMCS | No (read-only) |
|
||||||
|
| Last Name | WHMCS | No (read-only) |
|
||||||
|
| Customer Number | WHMCS Custom Field (ID: 198) | No (read-only) |
|
||||||
|
| Date of Birth | WHMCS Custom Field (ID: 201) | No (read-only) |
|
||||||
|
| Gender | WHMCS Custom Field (ID: 200) | No (read-only) |
|
||||||
|
| Address | WHMCS | Yes |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WHMCS custom field IDs (must match your WHMCS installation)
|
||||||
|
WHMCS_CUSTOMER_NUMBER_FIELD_ID=198 # Default
|
||||||
|
WHMCS_DOB_FIELD_ID=201 # Default
|
||||||
|
WHMCS_GENDER_FIELD_ID=200 # Default
|
||||||
|
```
|
||||||
|
|
||||||
|
If customer number, DOB, or gender aren't showing, verify these field IDs match your WHMCS custom fields.
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
# Shop Experience UX Improvements
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The shop experience has been significantly improved to be more user-friendly and less disruptive. Instead of redirecting users to a full signup page when they want to configure a plan, we now show plan context and use a modal for authentication.
|
|
||||||
|
|
||||||
## Key Improvements
|
|
||||||
|
|
||||||
### 1. **AuthModal Component** ✨
|
|
||||||
|
|
||||||
- **Location**: `apps/portal/src/features/auth/components/AuthModal/`
|
|
||||||
- **Purpose**: Reusable modal component for signup/login that doesn't break the shopping flow
|
|
||||||
- **Features**:
|
|
||||||
- Overlay design that keeps users in context
|
|
||||||
- Toggle between signup and login modes
|
|
||||||
- Automatic redirect after successful authentication
|
|
||||||
- Customizable title and description
|
|
||||||
- Responsive design
|
|
||||||
|
|
||||||
### 2. **Improved Configure Pages**
|
|
||||||
|
|
||||||
Both `PublicInternetConfigureView` and `PublicSimConfigureView` now:
|
|
||||||
|
|
||||||
- **Show plan information** before requiring authentication
|
|
||||||
- Plan name, description, pricing
|
|
||||||
- Plan features and badges
|
|
||||||
- Visual plan summary card
|
|
||||||
- **Use modal authentication** instead of full-page redirects
|
|
||||||
- Users stay on the configure page
|
|
||||||
- Can see what they're signing up for
|
|
||||||
- Better context preservation
|
|
||||||
- **Better loading and error states**
|
|
||||||
- Skeleton loaders while fetching plan data
|
|
||||||
- Clear error messages if plan not found
|
|
||||||
|
|
||||||
### 3. **Improved Plan Card CTAs**
|
|
||||||
|
|
||||||
- Changed button labels from "Create account" to "Get started" (more inviting)
|
|
||||||
- Plan cards now link to configure pages instead of directly to signup
|
|
||||||
- Configure pages show plan context before requiring auth
|
|
||||||
|
|
||||||
### 4. **Better User Flow**
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
|
|
||||||
1. User browses plans
|
|
||||||
2. Clicks "Create account" → Redirected to full signup page
|
|
||||||
3. Loses context of what plan they selected
|
|
||||||
4. Must navigate back after signup
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
|
|
||||||
1. User browses plans
|
|
||||||
2. Clicks "Get started" → Goes to configure page
|
|
||||||
3. Sees plan details and context
|
|
||||||
4. Clicks "Create account" → Modal opens
|
|
||||||
5. Completes signup/login in modal
|
|
||||||
6. Automatically redirected to authenticated configure page
|
|
||||||
7. Never loses context
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Components Created
|
|
||||||
|
|
||||||
- `AuthModal` - Reusable authentication modal
|
|
||||||
- Updated `PublicInternetConfigureView` - Shows plan context with modal auth
|
|
||||||
- Updated `PublicSimConfigureView` - Shows plan context with modal auth
|
|
||||||
|
|
||||||
### Components Updated
|
|
||||||
|
|
||||||
- `LoginForm` - Added `redirectTo` prop for modal usage
|
|
||||||
- `PublicInternetPlans` - Improved CTAs and redirects
|
|
||||||
- `PublicSimPlans` - Improved CTAs and redirects
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
- **Context Preservation**: Users always see what plan they're configuring
|
|
||||||
- **Progressive Disclosure**: Plan details shown before requiring authentication
|
|
||||||
- **Non-Blocking**: Modal can be closed to continue browsing
|
|
||||||
- **Seamless Flow**: Automatic redirect after authentication maintains user intent
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Better UX**: Users don't lose context when authenticating
|
|
||||||
2. **Higher Conversion**: Less friction in the signup process
|
|
||||||
3. **Clearer Intent**: Users see what they're signing up for
|
|
||||||
4. **Professional Feel**: Modal-based auth feels more modern
|
|
||||||
5. **Flexible**: Easy to reuse AuthModal in other parts of the app
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements for the future:
|
|
||||||
|
|
||||||
- Add "Continue as guest" option (if business logic allows)
|
|
||||||
- Show more plan details before auth (pricing breakdown, installation options)
|
|
||||||
- Add plan comparison before auth
|
|
||||||
- Remember selected plan in localStorage for returning visitors
|
|
||||||
- Add social login options in the modal
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Subscriptions Domain
|
* Subscriptions Domain
|
||||||
*
|
*
|
||||||
* Exports all subscription-related contracts, schemas, and provider mappers.
|
* Exports all subscription-related contracts, schemas, and provider mappers.
|
||||||
*
|
*
|
||||||
* Types are derived from Zod schemas (Schema-First Approach)
|
* Types are derived from Zod schemas (Schema-First Approach)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -23,7 +23,18 @@ export type {
|
|||||||
SubscriptionStats,
|
SubscriptionStats,
|
||||||
SimActionResponse,
|
SimActionResponse,
|
||||||
SimPlanChangeResult,
|
SimPlanChangeResult,
|
||||||
} from './schema.js';
|
// Internet cancellation types
|
||||||
|
InternetCancellationMonth,
|
||||||
|
InternetCancellationPreview,
|
||||||
|
InternetCancelRequest,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
// Re-export schemas for validation
|
||||||
|
export {
|
||||||
|
internetCancellationMonthSchema,
|
||||||
|
internetCancellationPreviewSchema,
|
||||||
|
internetCancelRequestSchema,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
// Provider adapters
|
// Provider adapters
|
||||||
export * as Providers from "./providers/index.js";
|
export * as Providers from "./providers/index.js";
|
||||||
@ -36,6 +47,4 @@ export type {
|
|||||||
WhmcsProductListResponse,
|
WhmcsProductListResponse,
|
||||||
} from "./providers/whmcs/raw.types.js";
|
} from "./providers/whmcs/raw.types.js";
|
||||||
|
|
||||||
export {
|
export { whmcsProductListResponseSchema } from "./providers/whmcs/raw.types.js";
|
||||||
whmcsProductListResponseSchema,
|
|
||||||
} from "./providers/whmcs/raw.types.js";
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Subscriptions Domain - Schemas
|
* Subscriptions Domain - Schemas
|
||||||
*
|
*
|
||||||
* Zod validation schemas for subscription domain types.
|
* Zod validation schemas for subscription domain types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -119,3 +119,45 @@ export type SubscriptionList = z.infer<typeof subscriptionListSchema>;
|
|||||||
export type SubscriptionStats = z.infer<typeof subscriptionStatsSchema>;
|
export type SubscriptionStats = z.infer<typeof subscriptionStatsSchema>;
|
||||||
export type SimActionResponse = z.infer<typeof simActionResponseSchema>;
|
export type SimActionResponse = z.infer<typeof simActionResponseSchema>;
|
||||||
export type SimPlanChangeResult = z.infer<typeof simPlanChangeResultSchema>;
|
export type SimPlanChangeResult = z.infer<typeof simPlanChangeResultSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internet Cancellation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available cancellation month for the customer
|
||||||
|
*/
|
||||||
|
export const internetCancellationMonthSchema = z.object({
|
||||||
|
value: z.string(), // YYYY-MM format
|
||||||
|
label: z.string(), // Display label like "November 2025"
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internet cancellation preview response (service details + available months)
|
||||||
|
*/
|
||||||
|
export const internetCancellationPreviewSchema = z.object({
|
||||||
|
productName: z.string(),
|
||||||
|
billingAmount: z.number(),
|
||||||
|
nextDueDate: z.string().optional(),
|
||||||
|
registrationDate: z.string().optional(),
|
||||||
|
availableMonths: z.array(internetCancellationMonthSchema),
|
||||||
|
customerEmail: z.string(),
|
||||||
|
customerName: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internet cancellation request from customer
|
||||||
|
*/
|
||||||
|
export const internetCancelRequestSchema = z.object({
|
||||||
|
cancellationMonth: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||||
|
confirmRead: z.boolean(),
|
||||||
|
confirmCancel: z.boolean(),
|
||||||
|
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||||
|
comments: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InternetCancellationMonth = z.infer<typeof internetCancellationMonthSchema>;
|
||||||
|
export type InternetCancellationPreview = z.infer<typeof internetCancellationPreviewSchema>;
|
||||||
|
export type InternetCancelRequest = z.infer<typeof internetCancelRequestSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user