Enhance Opportunity Management and Eligibility Handling
- Updated SalesforceOpportunityService to allow filtering by stages during opportunity retrieval, improving flexibility in eligibility checks. - Integrated DistributedLockService into InternetCatalogService and OrderOrchestrator to prevent race conditions when creating or reusing opportunities. - Refactored opportunity matching logic to ensure proper handling of stages during eligibility requests and order placements. - Improved documentation to clarify the opportunity lifecycle and eligibility verification processes, ensuring better understanding for future development.
This commit is contained in:
parent
90ab71b94d
commit
a61c2dd68b
@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to:
|
|||||||
|
|
||||||
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
|
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
|
||||||
- `POST /api/auth/login` - Portal authentication
|
- `POST /api/auth/login` - Portal authentication
|
||||||
- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin
|
- `POST /api/auth/migrate` - Account migration from legacy portal
|
||||||
- `POST /api/auth/set-password` - Required after WHMCS link
|
- `POST /api/auth/set-password` - Required after WHMCS link
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
"/api/auth/request-password-reset",
|
"/api/auth/request-password-reset",
|
||||||
"/api/auth/reset-password", // Public auth endpoint for password reset
|
"/api/auth/reset-password", // Public auth endpoint for password reset
|
||||||
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
|
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
|
||||||
"/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking
|
"/api/auth/migrate", // Public auth endpoint for account migration
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/api/webhooks", // Webhooks typically don't use CSRF
|
"/api/webhooks", // Webhooks typically don't use CSRF
|
||||||
|
|||||||
@ -304,23 +304,28 @@ export class SalesforceOpportunityService {
|
|||||||
*/
|
*/
|
||||||
async findOpenOpportunityForAccount(
|
async findOpenOpportunityForAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
productType: OpportunityProductTypeValue
|
productType: OpportunityProductTypeValue,
|
||||||
|
options?: { stages?: OpportunityStageValue[] }
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
||||||
|
|
||||||
// Get the CommodityType value(s) that match this product type
|
// Get the CommodityType value(s) that match this product type
|
||||||
const commodityTypeValues = this.getCommodityTypesForProductType(productType);
|
const commodityTypeValues = this.getCommodityTypesForProductType(productType);
|
||||||
|
|
||||||
|
const stages =
|
||||||
|
Array.isArray(options?.stages) && options?.stages.length > 0
|
||||||
|
? options.stages
|
||||||
|
: OPEN_OPPORTUNITY_STAGES;
|
||||||
|
|
||||||
this.logger.debug("Looking for open Opportunity", {
|
this.logger.debug("Looking for open Opportunity", {
|
||||||
accountId: safeAccountId,
|
accountId: safeAccountId,
|
||||||
productType,
|
productType,
|
||||||
commodityTypes: commodityTypeValues,
|
commodityTypes: commodityTypeValues,
|
||||||
|
stages,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build stage filter for open stages
|
// Build stage filter for open stages
|
||||||
const stageList = OPEN_OPPORTUNITY_STAGES.map((s: OpportunityStageValue) => `'${s}'`).join(
|
const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", ");
|
||||||
", "
|
|
||||||
);
|
|
||||||
const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", ");
|
const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", ");
|
||||||
|
|
||||||
const soql = `
|
const soql = `
|
||||||
|
|||||||
@ -230,11 +230,11 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("link-whmcs")
|
@Post("migrate")
|
||||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
||||||
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
|
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
|
||||||
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
||||||
const result = await this.authFacade.linkWhmcsUser(linkData);
|
const result = await this.authFacade.linkWhmcsUser(linkData);
|
||||||
return linkWhmcsResponseSchema.parse(result);
|
return linkWhmcsResponseSchema.parse(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
|
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||||
@ -28,6 +29,7 @@ import {
|
|||||||
OPPORTUNITY_STAGE,
|
OPPORTUNITY_STAGE,
|
||||||
OPPORTUNITY_SOURCE,
|
OPPORTUNITY_SOURCE,
|
||||||
OPPORTUNITY_PRODUCT_TYPE,
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
|
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
|
||||||
} from "@customer-portal/domain/opportunity";
|
} from "@customer-portal/domain/opportunity";
|
||||||
|
|
||||||
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
||||||
@ -49,6 +51,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
@Inject(Logger) logger: Logger,
|
@Inject(Logger) logger: Logger,
|
||||||
private mappingsService: MappingsService,
|
private mappingsService: MappingsService,
|
||||||
private catalogCache: CatalogCacheService,
|
private catalogCache: CatalogCacheService,
|
||||||
|
private lockService: DistributedLockService,
|
||||||
private opportunityService: SalesforceOpportunityService,
|
private opportunityService: SalesforceOpportunityService,
|
||||||
private caseService: SalesforceCaseService
|
private caseService: SalesforceCaseService
|
||||||
) {
|
) {
|
||||||
@ -278,11 +281,28 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Find or create Opportunity for Internet eligibility
|
const lockKey = `internet:eligibility:${sfAccountId}`;
|
||||||
// Only match Introduction stage (not Ready/Post Processing - those have progressed)
|
|
||||||
|
const caseId = await this.lockService.withLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
|
// Idempotency: if we already have a pending request, return the existing request id.
|
||||||
|
const existing = await this.queryEligibilityDetails(sfAccountId);
|
||||||
|
if (existing.status === "pending" && existing.requestId) {
|
||||||
|
this.logger.log("Eligibility request already pending; returning existing request id", {
|
||||||
|
userId,
|
||||||
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
|
caseIdTail: existing.requestId.slice(-4),
|
||||||
|
});
|
||||||
|
return existing.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Find or create Opportunity for Internet eligibility
|
||||||
|
// Only match Introduction stage. "Ready"/"Post Processing"/"Active" indicate the journey progressed.
|
||||||
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount(
|
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||||
sfAccountId,
|
sfAccountId,
|
||||||
OPPORTUNITY_PRODUCT_TYPE.INTERNET
|
OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||||
|
{ stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY }
|
||||||
);
|
);
|
||||||
|
|
||||||
let opportunityCreated = false;
|
let opportunityCreated = false;
|
||||||
@ -302,7 +322,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build case description
|
// 2) Build case description
|
||||||
const subject = "Internet availability check request (Portal)";
|
const subject = "Internet availability check request (Portal)";
|
||||||
const descriptionLines: string[] = [
|
const descriptionLines: string[] = [
|
||||||
"Portal internet availability check requested.",
|
"Portal internet availability check requested.",
|
||||||
@ -318,27 +338,31 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
`RequestedAt: ${new Date().toISOString()}`,
|
`RequestedAt: ${new Date().toISOString()}`,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
// 3. Create Case linked to Opportunity
|
// 3) Create Case linked to Opportunity
|
||||||
const caseId = await this.caseService.createEligibilityCase({
|
const createdCaseId = await this.caseService.createEligibilityCase({
|
||||||
accountId: sfAccountId,
|
accountId: sfAccountId,
|
||||||
opportunityId,
|
opportunityId,
|
||||||
subject,
|
subject,
|
||||||
description: descriptionLines.join("\n"),
|
description: descriptionLines.join("\n"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Update Account eligibility status
|
// 4) Update Account eligibility status
|
||||||
await this.updateAccountEligibilityRequestState(sfAccountId, caseId);
|
await this.updateAccountEligibilityRequestState(sfAccountId, createdCaseId);
|
||||||
|
|
||||||
await this.catalogCache.invalidateEligibility(sfAccountId);
|
await this.catalogCache.invalidateEligibility(sfAccountId);
|
||||||
|
|
||||||
this.logger.log("Created eligibility Case linked to Opportunity", {
|
this.logger.log("Created eligibility Case linked to Opportunity", {
|
||||||
userId,
|
userId,
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
caseIdTail: caseId.slice(-4),
|
caseIdTail: createdCaseId.slice(-4),
|
||||||
opportunityIdTail: opportunityId.slice(-4),
|
opportunityIdTail: opportunityId.slice(-4),
|
||||||
opportunityCreated,
|
opportunityCreated,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return createdCaseId;
|
||||||
|
},
|
||||||
|
{ ttlMs: 10_000 }
|
||||||
|
);
|
||||||
|
|
||||||
return caseId;
|
return caseId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to create eligibility request", {
|
this.logger.error("Failed to create eligibility request", {
|
||||||
|
|||||||
@ -129,7 +129,8 @@ export class NotificationService {
|
|||||||
userId,
|
userId,
|
||||||
expiresAt: { gt: now },
|
expiresAt: { gt: now },
|
||||||
...(options?.includeDismissed ? {} : { dismissed: false }),
|
...(options?.includeDismissed ? {} : { dismissed: false }),
|
||||||
...(options?.includeRead ? {} : {}), // Include all by default for the list
|
// By default we include read notifications. If includeRead=false, filter them out.
|
||||||
|
...(options?.includeRead === false ? { read: false } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,561 +0,0 @@
|
|||||||
/**
|
|
||||||
* Opportunity Matching Service
|
|
||||||
*
|
|
||||||
* Resolves which Opportunity to use for orders based on matching rules.
|
|
||||||
* Handles finding existing Opportunities or creating new ones as needed.
|
|
||||||
*
|
|
||||||
* Uses existing Salesforce stages:
|
|
||||||
* - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled
|
|
||||||
*
|
|
||||||
* Matching Rules:
|
|
||||||
* 1. If order already has opportunityId → use it directly
|
|
||||||
* 2. For Internet orders → find Introduction/Ready stage Opportunity or create new
|
|
||||||
* 3. For SIM orders → find open Opportunity for account or create new
|
|
||||||
* 4. For VPN orders → create new Opportunity (no matching)
|
|
||||||
*
|
|
||||||
* @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Inject } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
|
||||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
|
||||||
import {
|
|
||||||
type OpportunityProductTypeValue,
|
|
||||||
type OpportunityStageValue,
|
|
||||||
type CancellationFormData,
|
|
||||||
type CancellationOpportunityData,
|
|
||||||
type CancellationEligibility,
|
|
||||||
OPPORTUNITY_STAGE,
|
|
||||||
OPPORTUNITY_SOURCE,
|
|
||||||
OPPORTUNITY_PRODUCT_TYPE,
|
|
||||||
APPLICATION_STAGE,
|
|
||||||
getCancellationEligibility,
|
|
||||||
validateCancellationMonth,
|
|
||||||
transformCancellationFormToOpportunityData,
|
|
||||||
} from "@customer-portal/domain/opportunity";
|
|
||||||
import type { OrderTypeValue } from "@customer-portal/domain/orders";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context for Opportunity resolution
|
|
||||||
*/
|
|
||||||
export interface OpportunityResolutionContext {
|
|
||||||
/** Salesforce Account ID */
|
|
||||||
accountId: string;
|
|
||||||
|
|
||||||
/** Order type (Internet, SIM, VPN) */
|
|
||||||
orderType: OrderTypeValue;
|
|
||||||
|
|
||||||
/** Existing Opportunity ID if provided with order */
|
|
||||||
existingOpportunityId?: string;
|
|
||||||
|
|
||||||
/** Source of the order (for new Opportunity creation) */
|
|
||||||
source?: "checkout" | "eligibility" | "order";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of Opportunity resolution
|
|
||||||
*/
|
|
||||||
export interface ResolvedOpportunity {
|
|
||||||
/** The Opportunity ID to use */
|
|
||||||
opportunityId: string;
|
|
||||||
|
|
||||||
/** Whether a new Opportunity was created */
|
|
||||||
wasCreated: boolean;
|
|
||||||
|
|
||||||
/** The stage of the Opportunity */
|
|
||||||
stage: OpportunityStageValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Service
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OpportunityMatchingService {
|
|
||||||
constructor(
|
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
|
||||||
private readonly lockService: DistributedLockService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Opportunity Resolution for Orders
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the Opportunity to use for an order
|
|
||||||
*
|
|
||||||
* This is the main entry point for Opportunity matching.
|
|
||||||
* It handles finding existing Opportunities or creating new ones.
|
|
||||||
*
|
|
||||||
* Uses a distributed lock to prevent race conditions when multiple
|
|
||||||
* concurrent requests try to create Opportunities for the same account.
|
|
||||||
*
|
|
||||||
* @param context - Resolution context with account and order details
|
|
||||||
* @returns Resolved Opportunity with ID and metadata
|
|
||||||
*/
|
|
||||||
async resolveOpportunityForOrder(
|
|
||||||
context: OpportunityResolutionContext
|
|
||||||
): Promise<ResolvedOpportunity> {
|
|
||||||
this.logger.log("Resolving Opportunity for order", {
|
|
||||||
accountId: context.accountId,
|
|
||||||
orderType: context.orderType,
|
|
||||||
hasExistingOpportunityId: !!context.existingOpportunityId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rule 1: If order already has opportunityId, validate and use it
|
|
||||||
if (context.existingOpportunityId) {
|
|
||||||
return this.useExistingOpportunity(context.existingOpportunityId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2-4: Find or create based on order type
|
|
||||||
const productType = this.mapOrderTypeToProductType(context.orderType);
|
|
||||||
|
|
||||||
if (!productType) {
|
|
||||||
this.logger.warn("Unknown order type, creating new Opportunity", {
|
|
||||||
orderType: context.orderType,
|
|
||||||
});
|
|
||||||
return this.createNewOpportunity(context, "Internet");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use distributed lock to prevent race conditions
|
|
||||||
// Lock key is specific to account + product type
|
|
||||||
const lockKey = `opportunity:${context.accountId}:${productType}`;
|
|
||||||
|
|
||||||
return this.lockService.withLock(
|
|
||||||
lockKey,
|
|
||||||
async () => {
|
|
||||||
// Re-check for existing Opportunity after acquiring lock
|
|
||||||
// Another request may have created one while we were waiting
|
|
||||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
|
||||||
context.accountId,
|
|
||||||
productType
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingOppId) {
|
|
||||||
this.logger.debug("Found existing Opportunity after acquiring lock", {
|
|
||||||
opportunityId: existingOppId,
|
|
||||||
});
|
|
||||||
return this.useExistingOpportunity(existingOppId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No existing Opportunity found - create new one
|
|
||||||
return this.createNewOpportunity(context, productType);
|
|
||||||
},
|
|
||||||
{ ttlMs: 10_000 } // 10 second lock TTL
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Opportunity Creation Triggers
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Opportunity at eligibility request (Internet only)
|
|
||||||
*
|
|
||||||
* Called when customer requests Internet eligibility check.
|
|
||||||
* Uses distributed lock to prevent duplicate Opportunities.
|
|
||||||
* First checks for existing open Opportunity before creating.
|
|
||||||
*
|
|
||||||
* NOTE: The Case is linked TO the Opportunity via Case.OpportunityId,
|
|
||||||
* not the other way around. So we don't need to store Case ID on Opportunity.
|
|
||||||
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
|
|
||||||
*
|
|
||||||
* @param accountId - Salesforce Account ID
|
|
||||||
* @returns Opportunity ID (existing or newly created)
|
|
||||||
*/
|
|
||||||
async createOpportunityForEligibility(accountId: string): Promise<string> {
|
|
||||||
this.logger.log("Creating Opportunity for Internet eligibility request", {
|
|
||||||
accountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.INTERNET}`;
|
|
||||||
|
|
||||||
return this.lockService.withLock(
|
|
||||||
lockKey,
|
|
||||||
async () => {
|
|
||||||
// Check for existing open Opportunity first
|
|
||||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
|
||||||
accountId,
|
|
||||||
OPPORTUNITY_PRODUCT_TYPE.INTERNET
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingOppId) {
|
|
||||||
this.logger.debug("Found existing Internet Opportunity, reusing", {
|
|
||||||
opportunityId: existingOppId,
|
|
||||||
});
|
|
||||||
return existingOppId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new Opportunity
|
|
||||||
const opportunityId = await this.opportunityService.createOpportunity({
|
|
||||||
accountId,
|
|
||||||
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
|
||||||
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
|
||||||
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
|
|
||||||
applicationStage: APPLICATION_STAGE.INTRO_1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return opportunityId;
|
|
||||||
},
|
|
||||||
{ ttlMs: 10_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Opportunity at checkout registration (SIM only)
|
|
||||||
*
|
|
||||||
* Called when customer creates account during SIM checkout.
|
|
||||||
* Uses distributed lock to prevent duplicate Opportunities.
|
|
||||||
* First checks for existing open Opportunity before creating.
|
|
||||||
*
|
|
||||||
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
|
|
||||||
*
|
|
||||||
* @param accountId - Salesforce Account ID
|
|
||||||
* @returns Opportunity ID (existing or newly created)
|
|
||||||
*/
|
|
||||||
async createOpportunityForCheckoutRegistration(accountId: string): Promise<string> {
|
|
||||||
this.logger.log("Creating Opportunity for SIM checkout registration", {
|
|
||||||
accountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.SIM}`;
|
|
||||||
|
|
||||||
return this.lockService.withLock(
|
|
||||||
lockKey,
|
|
||||||
async () => {
|
|
||||||
// Check for existing open Opportunity first
|
|
||||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
|
||||||
accountId,
|
|
||||||
OPPORTUNITY_PRODUCT_TYPE.SIM
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingOppId) {
|
|
||||||
this.logger.debug("Found existing SIM Opportunity, reusing", {
|
|
||||||
opportunityId: existingOppId,
|
|
||||||
});
|
|
||||||
return existingOppId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new Opportunity
|
|
||||||
const opportunityId = await this.opportunityService.createOpportunity({
|
|
||||||
accountId,
|
|
||||||
productType: OPPORTUNITY_PRODUCT_TYPE.SIM,
|
|
||||||
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
|
||||||
source: OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION,
|
|
||||||
applicationStage: APPLICATION_STAGE.INTRO_1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return opportunityId;
|
|
||||||
},
|
|
||||||
{ ttlMs: 10_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Lifecycle Stage Updates
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Opportunity stage to Ready (eligible)
|
|
||||||
*
|
|
||||||
* Called when Internet eligibility is confirmed.
|
|
||||||
*
|
|
||||||
* @param opportunityId - Opportunity ID to update
|
|
||||||
*/
|
|
||||||
async markEligible(opportunityId: string): Promise<void> {
|
|
||||||
this.logger.log("Marking Opportunity as eligible (Ready)", { opportunityId });
|
|
||||||
await this.opportunityService.updateStage(
|
|
||||||
opportunityId,
|
|
||||||
OPPORTUNITY_STAGE.READY,
|
|
||||||
"Eligibility confirmed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Opportunity stage to Void (not eligible)
|
|
||||||
*
|
|
||||||
* Called when Internet eligibility check fails.
|
|
||||||
*
|
|
||||||
* @param opportunityId - Opportunity ID to update
|
|
||||||
*/
|
|
||||||
async markNotEligible(opportunityId: string): Promise<void> {
|
|
||||||
this.logger.log("Marking Opportunity as not eligible (Void)", { opportunityId });
|
|
||||||
await this.opportunityService.updateStage(
|
|
||||||
opportunityId,
|
|
||||||
OPPORTUNITY_STAGE.VOID,
|
|
||||||
"Not eligible for service"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Opportunity stage after order placement
|
|
||||||
*
|
|
||||||
* Called after order is successfully created in Salesforce.
|
|
||||||
*
|
|
||||||
* @param opportunityId - Opportunity ID to update
|
|
||||||
*/
|
|
||||||
async markOrderPlaced(opportunityId: string): Promise<void> {
|
|
||||||
this.logger.log("Marking Opportunity as order placed (Post Processing)", { opportunityId });
|
|
||||||
await this.opportunityService.updateStage(
|
|
||||||
opportunityId,
|
|
||||||
OPPORTUNITY_STAGE.POST_PROCESSING,
|
|
||||||
"Order placed via portal"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Opportunity stage after provisioning
|
|
||||||
*
|
|
||||||
* Called after order is successfully provisioned.
|
|
||||||
*
|
|
||||||
* @param opportunityId - Opportunity ID to update
|
|
||||||
* @param whmcsServiceId - WHMCS Service ID to link
|
|
||||||
*/
|
|
||||||
async markProvisioned(opportunityId: string, whmcsServiceId: number): Promise<void> {
|
|
||||||
this.logger.log("Marking Opportunity as active", {
|
|
||||||
opportunityId,
|
|
||||||
whmcsServiceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update stage to Active
|
|
||||||
await this.opportunityService.updateStage(
|
|
||||||
opportunityId,
|
|
||||||
OPPORTUNITY_STAGE.ACTIVE,
|
|
||||||
"Service provisioned successfully"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Link WHMCS Service ID for cancellation workflows
|
|
||||||
await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, whmcsServiceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Opportunity for eligibility result
|
|
||||||
*
|
|
||||||
* Called when eligibility check is completed (eligible or not).
|
|
||||||
*
|
|
||||||
* @param opportunityId - Opportunity ID to update
|
|
||||||
* @param isEligible - Whether the customer is eligible
|
|
||||||
*/
|
|
||||||
async updateEligibilityResult(opportunityId: string, isEligible: boolean): Promise<void> {
|
|
||||||
if (isEligible) {
|
|
||||||
await this.markEligible(opportunityId);
|
|
||||||
} else {
|
|
||||||
await this.markNotEligible(opportunityId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Cancellation Flow
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cancellation eligibility for a customer
|
|
||||||
*
|
|
||||||
* Calculates available cancellation months based on the 25th rule.
|
|
||||||
*
|
|
||||||
* @returns Cancellation eligibility details
|
|
||||||
*/
|
|
||||||
getCancellationEligibility(): CancellationEligibility {
|
|
||||||
return getCancellationEligibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a cancellation request
|
|
||||||
*
|
|
||||||
* Checks:
|
|
||||||
* - Month format is valid
|
|
||||||
* - Month is not before earliest allowed
|
|
||||||
* - Both confirmations are checked
|
|
||||||
*
|
|
||||||
* @param formData - Form data from customer
|
|
||||||
* @returns Validation result
|
|
||||||
*/
|
|
||||||
validateCancellationRequest(formData: CancellationFormData): { valid: boolean; error?: string } {
|
|
||||||
// Validate month
|
|
||||||
const monthValidation = validateCancellationMonth(formData.cancellationMonth);
|
|
||||||
if (!monthValidation.valid) {
|
|
||||||
return monthValidation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate confirmations
|
|
||||||
if (!formData.confirmTermsRead) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "You must confirm you have read the cancellation terms",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.confirmMonthEndCancellation) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "You must confirm you understand cancellation is at month end",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a cancellation request
|
|
||||||
*
|
|
||||||
* Finds the Opportunity for the service and updates it with cancellation data.
|
|
||||||
*
|
|
||||||
* @param whmcsServiceId - WHMCS Service ID
|
|
||||||
* @param formData - Cancellation form data
|
|
||||||
* @returns Result of the cancellation update
|
|
||||||
*/
|
|
||||||
async processCancellationRequest(
|
|
||||||
whmcsServiceId: number,
|
|
||||||
formData: CancellationFormData
|
|
||||||
): Promise<{ success: boolean; opportunityId?: string; error?: string }> {
|
|
||||||
this.logger.log("Processing cancellation request", {
|
|
||||||
whmcsServiceId,
|
|
||||||
cancellationMonth: formData.cancellationMonth,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
const validation = this.validateCancellationRequest(formData);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return { success: false, error: validation.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the Opportunity
|
|
||||||
const opportunityId =
|
|
||||||
await this.opportunityService.findOpportunityByWhmcsServiceId(whmcsServiceId);
|
|
||||||
|
|
||||||
if (!opportunityId) {
|
|
||||||
this.logger.warn("No Opportunity found for WHMCS Service", { whmcsServiceId });
|
|
||||||
// This is not necessarily an error - older services may not have Opportunities
|
|
||||||
// Return success but note no Opportunity was found
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform form data to Opportunity data
|
|
||||||
const cancellationData: CancellationOpportunityData =
|
|
||||||
transformCancellationFormToOpportunityData(formData);
|
|
||||||
|
|
||||||
// Update the Opportunity
|
|
||||||
await this.opportunityService.updateCancellationData(opportunityId, cancellationData);
|
|
||||||
|
|
||||||
this.logger.log("Cancellation request processed successfully", {
|
|
||||||
opportunityId,
|
|
||||||
scheduledDate: cancellationData.scheduledCancellationDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, opportunityId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Private Helpers
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use an existing Opportunity (validate it exists)
|
|
||||||
*/
|
|
||||||
private async useExistingOpportunity(opportunityId: string): Promise<ResolvedOpportunity> {
|
|
||||||
this.logger.debug("Using existing Opportunity", { opportunityId });
|
|
||||||
|
|
||||||
const opportunity = await this.opportunityService.getOpportunityById(opportunityId);
|
|
||||||
|
|
||||||
if (!opportunity) {
|
|
||||||
this.logger.warn("Existing Opportunity not found, will need to create new", {
|
|
||||||
opportunityId,
|
|
||||||
});
|
|
||||||
throw new Error(`Opportunity ${opportunityId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
opportunityId: opportunity.id,
|
|
||||||
wasCreated: false,
|
|
||||||
stage: opportunity.stage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new Opportunity for an order
|
|
||||||
*/
|
|
||||||
private async createNewOpportunity(
|
|
||||||
context: OpportunityResolutionContext,
|
|
||||||
productType: OpportunityProductTypeValue
|
|
||||||
): Promise<ResolvedOpportunity> {
|
|
||||||
this.logger.debug("Creating new Opportunity for order", {
|
|
||||||
accountId: context.accountId,
|
|
||||||
productType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stage = this.determineInitialStage(context);
|
|
||||||
const source = this.determineSource(context);
|
|
||||||
|
|
||||||
// Salesforce workflow auto-generates Opportunity Name
|
|
||||||
const opportunityId = await this.opportunityService.createOpportunity({
|
|
||||||
accountId: context.accountId,
|
|
||||||
productType,
|
|
||||||
stage,
|
|
||||||
source,
|
|
||||||
applicationStage: APPLICATION_STAGE.INTRO_1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
opportunityId,
|
|
||||||
wasCreated: true,
|
|
||||||
stage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map OrderType to OpportunityProductType
|
|
||||||
*/
|
|
||||||
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue | null {
|
|
||||||
switch (orderType) {
|
|
||||||
case "Internet":
|
|
||||||
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
|
||||||
case "SIM":
|
|
||||||
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
|
||||||
case "VPN":
|
|
||||||
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine initial stage for new Opportunity
|
|
||||||
*/
|
|
||||||
private determineInitialStage(context: OpportunityResolutionContext): OpportunityStageValue {
|
|
||||||
// If coming from eligibility request
|
|
||||||
if (context.source === "eligibility") {
|
|
||||||
return OPPORTUNITY_STAGE.INTRODUCTION;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If coming from checkout registration
|
|
||||||
if (context.source === "checkout") {
|
|
||||||
return OPPORTUNITY_STAGE.INTRODUCTION;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: order placement - go to Post Processing
|
|
||||||
return OPPORTUNITY_STAGE.POST_PROCESSING;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine source for new Opportunity
|
|
||||||
*/
|
|
||||||
private determineSource(
|
|
||||||
context: OpportunityResolutionContext
|
|
||||||
): (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE] {
|
|
||||||
switch (context.source) {
|
|
||||||
case "eligibility":
|
|
||||||
return OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY;
|
|
||||||
case "checkout":
|
|
||||||
return OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION;
|
|
||||||
case "order":
|
|
||||||
default:
|
|
||||||
return OPPORTUNITY_SOURCE.ORDER_PLACEMENT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
|
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||||
import { OrderValidator } from "./order-validator.service.js";
|
import { OrderValidator } from "./order-validator.service.js";
|
||||||
import { OrderBuilder } from "./order-builder.service.js";
|
import { OrderBuilder } from "./order-builder.service.js";
|
||||||
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
OPPORTUNITY_SOURCE,
|
OPPORTUNITY_SOURCE,
|
||||||
OPPORTUNITY_PRODUCT_TYPE,
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
type OpportunityProductTypeValue,
|
type OpportunityProductTypeValue,
|
||||||
|
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
|
||||||
} from "@customer-portal/domain/opportunity";
|
} from "@customer-portal/domain/opportunity";
|
||||||
|
|
||||||
type OrderDetailsResponse = OrderDetails;
|
type OrderDetailsResponse = OrderDetails;
|
||||||
@ -29,6 +31,7 @@ export class OrderOrchestrator {
|
|||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
private readonly salesforceOrderService: SalesforceOrderService,
|
private readonly salesforceOrderService: SalesforceOrderService,
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
|
private readonly lockService: DistributedLockService,
|
||||||
private readonly orderValidator: OrderValidator,
|
private readonly orderValidator: OrderValidator,
|
||||||
private readonly orderBuilder: OrderBuilder,
|
private readonly orderBuilder: OrderBuilder,
|
||||||
private readonly orderItemBuilder: OrderItemBuilder,
|
private readonly orderItemBuilder: OrderItemBuilder,
|
||||||
@ -163,10 +166,16 @@ export class OrderOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to find existing open Opportunity (Introduction or Ready stage)
|
const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
|
||||||
|
|
||||||
|
return await this.lockService.withLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
|
// Try to find existing matchable Opportunity (Introduction or Ready stage)
|
||||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||||
safeAccountId,
|
safeAccountId,
|
||||||
productType
|
productType,
|
||||||
|
{ stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingOppId) {
|
if (existingOppId) {
|
||||||
@ -191,6 +200,9 @@ export class OrderOrchestrator {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return newOppId;
|
return newOppId;
|
||||||
|
},
|
||||||
|
{ ttlMs: 10_000 }
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.warn("Failed to resolve Opportunity for order", {
|
this.logger.warn("Failed to resolve Opportunity for order", {
|
||||||
orderType,
|
orderType,
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView";
|
|
||||||
|
|
||||||
export default function LinkWhmcsPage() {
|
|
||||||
return <LinkWhmcsView />;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import MigrateAccountView from "@/features/auth/views/MigrateAccountView";
|
||||||
|
|
||||||
|
export default function MigrateAccountPage() {
|
||||||
|
return <MigrateAccountView />;
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ export interface AuthLayoutProps {
|
|||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
backHref?: string;
|
backHref?: string;
|
||||||
backLabel?: string;
|
backLabel?: string;
|
||||||
|
/** Use wider layout for forms with more content like signup */
|
||||||
|
wide?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthLayout({
|
export function AuthLayout({
|
||||||
@ -20,10 +22,13 @@ export function AuthLayout({
|
|||||||
showBackButton = false,
|
showBackButton = false,
|
||||||
backHref = "/",
|
backHref = "/",
|
||||||
backLabel = "Back to Home",
|
backLabel = "Back to Home",
|
||||||
|
wide = false,
|
||||||
}: AuthLayoutProps) {
|
}: AuthLayoutProps) {
|
||||||
|
const maxWidth = wide ? "max-w-xl" : "max-w-md";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center">
|
<div className="w-full flex flex-col items-center">
|
||||||
<div className="w-full max-w-md">
|
<div className={`w-full ${maxWidth}`}>
|
||||||
{showBackButton && (
|
{showBackButton && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
@ -56,7 +61,7 @@ export function AuthLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 w-full max-w-md">
|
<div className={`mt-8 w-full ${maxWidth}`}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Subtle gradient glow behind card */}
|
{/* Subtle gradient glow behind card */}
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-primary/10 via-transparent to-primary/10 rounded-[1.75rem] blur-xl opacity-50" />
|
<div className="absolute -inset-1 bg-gradient-to-r from-primary/10 via-transparent to-primary/10 rounded-[1.75rem] blur-xl opacity-50" />
|
||||||
|
|||||||
@ -76,14 +76,25 @@ export function InlineAuthSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
||||||
{mode === "signup" && (
|
{mode === "signup" && (
|
||||||
<SignupForm redirectTo={redirectTo} className="max-w-none" showFooterLinks={false} />
|
<>
|
||||||
|
<h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Set up your portal access in a few simple steps.
|
||||||
|
</p>
|
||||||
|
<SignupForm redirectTo={redirectTo} showFooterLinks={false} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{mode === "login" && (
|
{mode === "login" && (
|
||||||
<LoginForm redirectTo={redirectTo} showSignupLink={false} className="max-w-none" />
|
<>
|
||||||
|
<h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
|
||||||
|
<LoginForm redirectTo={redirectTo} showSignupLink={false} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{mode === "migrate" && (
|
{mode === "migrate" && (
|
||||||
<div className="bg-card border border-border rounded-xl p-5 shadow-[var(--cp-shadow-1)]">
|
<>
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
|
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Use your legacy portal credentials to transfer your account.
|
Use your legacy portal credentials to transfer your account.
|
||||||
@ -101,9 +112,10 @@ export function InlineAuthSection({
|
|||||||
router.push(safeRedirect);
|
router.push(safeRedirect);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{highlights.length > 0 && (
|
{highlights.length > 0 && (
|
||||||
<div className="mt-6 pt-6 border-t border-border">
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export function LoginForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-md mx-auto ${className}`}>
|
<div className={`w-full ${className}`}>
|
||||||
<form onSubmit={event => void handleSubmit(event)} className="space-y-6">
|
<form onSubmit={event => void handleSubmit(event)} className="space-y-6">
|
||||||
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
|
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@ -44,14 +44,13 @@ export function MultiStepForm({
|
|||||||
onStepChange?.(currentStep);
|
onStepChange?.(currentStep);
|
||||||
}, [currentStep, onStepChange]);
|
}, [currentStep, onStepChange]);
|
||||||
|
|
||||||
const totalSteps = steps.length;
|
|
||||||
const step = steps[currentStep] ?? steps[0];
|
const step = steps[currentStep] ?? steps[0];
|
||||||
const isFirstStep = currentStep === 0;
|
const isFirstStep = currentStep === 0;
|
||||||
const disableNext = isSubmitting || (!canProceed && !isLastStep);
|
const disableNext = isSubmitting || (!canProceed && !isLastStep);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`}>
|
<div className={`space-y-6 ${className}`}>
|
||||||
{/* Step Indicators */}
|
{/* Simple Step Indicators */}
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{steps.map((s, idx) => {
|
{steps.map((s, idx) => {
|
||||||
const isCompleted = idx < currentStep;
|
const isCompleted = idx < currentStep;
|
||||||
@ -61,20 +60,20 @@ export function MultiStepForm({
|
|||||||
<div key={s.key} className="flex items-center">
|
<div key={s.key} className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium
|
flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold
|
||||||
transition-all duration-200
|
transition-colors duration-200
|
||||||
${
|
${
|
||||||
isCompleted
|
isCompleted
|
||||||
? "bg-success text-success-foreground"
|
? "bg-success text-success-foreground"
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? "bg-primary text-primary-foreground ring-4 ring-primary/15"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1}
|
{isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1}
|
||||||
</div>
|
</div>
|
||||||
{idx < totalSteps - 1 && (
|
{idx < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${
|
className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${
|
||||||
isCompleted ? "bg-success" : "bg-border"
|
isCompleted ? "bg-success" : "bg-border"
|
||||||
|
|||||||
@ -27,11 +27,17 @@ import { PasswordStep } from "./steps/PasswordStep";
|
|||||||
* - confirmPassword: UI-only field for password confirmation
|
* - confirmPassword: UI-only field for password confirmation
|
||||||
* - phoneCountryCode: Separate field for country code input
|
* - phoneCountryCode: Separate field for country code input
|
||||||
* - address: Required addressFormSchema (domain schema makes it optional)
|
* - address: Required addressFormSchema (domain schema makes it optional)
|
||||||
|
* - dateOfBirth: Required for signup (domain schema makes it optional)
|
||||||
|
* - gender: Required for signup (domain schema makes it optional)
|
||||||
*/
|
*/
|
||||||
|
const genderSchema = z.enum(["male", "female", "other"]);
|
||||||
|
|
||||||
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
|
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||||
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
||||||
address: addressFormSchema,
|
address: addressFormSchema,
|
||||||
|
dateOfBirth: z.string().min(1, "Date of birth is required"),
|
||||||
|
gender: genderSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const signupFormSchema = signupFormBaseSchema
|
const signupFormSchema = signupFormBaseSchema
|
||||||
@ -132,8 +138,8 @@ export function SignupForm({
|
|||||||
phone: "",
|
phone: "",
|
||||||
phoneCountryCode: "+81",
|
phoneCountryCode: "+81",
|
||||||
company: "",
|
company: "",
|
||||||
dateOfBirth: undefined,
|
dateOfBirth: "",
|
||||||
gender: undefined,
|
gender: "" as unknown as "male" | "female" | "other", // Will be validated on submit
|
||||||
address: {
|
address: {
|
||||||
address1: "",
|
address1: "",
|
||||||
address2: "",
|
address2: "",
|
||||||
@ -257,8 +263,7 @@ export function SignupForm({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-lg mx-auto ${className}`}>
|
<div className={`w-full ${className}`}>
|
||||||
<div className="bg-card text-card-foreground shadow-[var(--cp-shadow-2)] rounded-2xl border border-border p-6 sm:p-8">
|
|
||||||
<MultiStepForm
|
<MultiStepForm
|
||||||
steps={steps}
|
steps={steps}
|
||||||
currentStep={step}
|
currentStep={step}
|
||||||
@ -289,7 +294,7 @@ export function SignupForm({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Existing customer?{" "}
|
Existing customer?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/auth/link-whmcs"
|
href="/auth/migrate"
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
className="font-medium text-primary hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
Migrate your account
|
Migrate your account
|
||||||
@ -298,6 +303,5 @@ export function SignupForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
|
|
||||||
@ -30,7 +29,6 @@ interface AccountStepProps {
|
|||||||
export function AccountStep({ form }: AccountStepProps) {
|
export function AccountStep({ form }: AccountStepProps) {
|
||||||
const { values, errors, touched, setValue, setTouchedField } = form;
|
const { values, errors, touched, setValue, setTouchedField } = form;
|
||||||
const getError = (field: string) => (touched[field] ? errors[field] : undefined);
|
const getError = (field: string) => (touched[field] ? errors[field] : undefined);
|
||||||
const [showOptional, setShowOptional] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -112,21 +110,9 @@ export function AccountStep({ form }: AccountStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="pt-2">
|
{/* DOB + Gender (Required) */}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
|
||||||
onClick={() => setShowOptional(s => !s)}
|
|
||||||
>
|
|
||||||
{showOptional ? "Hide optional details" : "Add optional details"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showOptional && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* DOB + Gender (Optional WHMCS custom fields) */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
|
<FormField label="Date of Birth" error={getError("dateOfBirth")} required>
|
||||||
<Input
|
<Input
|
||||||
name="bday"
|
name="bday"
|
||||||
type="date"
|
type="date"
|
||||||
@ -137,7 +123,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Gender" error={getError("gender")} helperText="Optional">
|
<FormField label="Gender" error={getError("gender")} required>
|
||||||
<select
|
<select
|
||||||
name="sex"
|
name="sex"
|
||||||
value={values.gender ?? ""}
|
value={values.gender ?? ""}
|
||||||
@ -174,7 +160,5 @@ export function AccountStep({ form }: AccountStepProps) {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -280,7 +280,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
|
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/link-whmcs", {
|
const response = await apiClient.POST("/api/auth/migrate", {
|
||||||
body: linkRequest,
|
body: linkRequest,
|
||||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Link WHMCS View - Account migration page
|
* Migrate Account View - Account migration page
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
@ -10,7 +10,7 @@ import { AuthLayout } from "../components";
|
|||||||
import { LinkWhmcsForm } from "@/features/auth/components";
|
import { LinkWhmcsForm } from "@/features/auth/components";
|
||||||
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
|
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
export function LinkWhmcsView() {
|
export function MigrateAccountView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -32,7 +32,7 @@ export function LinkWhmcsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="bg-card text-card-foreground rounded-lg border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-1">
|
<h2 className="text-lg font-semibold text-foreground mb-1">
|
||||||
Enter Legacy Portal Credentials
|
Enter Legacy Portal Credentials
|
||||||
</h2>
|
</h2>
|
||||||
@ -51,7 +51,7 @@ export function LinkWhmcsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<div className="flex justify-center gap-6 text-sm">
|
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
New customer?{" "}
|
New customer?{" "}
|
||||||
<Link href="/auth/signup" className="text-primary hover:underline">
|
<Link href="/auth/signup" className="text-primary hover:underline">
|
||||||
@ -92,4 +92,4 @@ export function LinkWhmcsView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LinkWhmcsView;
|
export default MigrateAccountView;
|
||||||
@ -15,7 +15,7 @@ function SetPasswordContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
router.replace("/auth/link-whmcs");
|
router.replace("/auth/migrate");
|
||||||
}
|
}
|
||||||
}, [email, router]);
|
}, [email, router]);
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ function SetPasswordContent() {
|
|||||||
again so we can verify your account.
|
again so we can verify your account.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/link-whmcs"
|
href="/auth/migrate"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||||
>
|
>
|
||||||
Go to account transfer
|
Go to account transfer
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function SignupView() {
|
|||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Create Your Account"
|
title="Create Your Account"
|
||||||
subtitle="Set up your portal access in a few simple steps"
|
subtitle="Set up your portal access in a few simple steps"
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
<SignupForm />
|
<SignupForm />
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|||||||
@ -3,4 +3,4 @@ export { SignupView } from "./SignupView";
|
|||||||
export { ForgotPasswordView } from "./ForgotPasswordView";
|
export { ForgotPasswordView } from "./ForgotPasswordView";
|
||||||
export { ResetPasswordView } from "./ResetPasswordView";
|
export { ResetPasswordView } from "./ResetPasswordView";
|
||||||
export { SetPasswordView } from "./SetPasswordView";
|
export { SetPasswordView } from "./SetPasswordView";
|
||||||
export { LinkWhmcsView } from "./LinkWhmcsView";
|
export { MigrateAccountView } from "./MigrateAccountView";
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Button } from "@/components/atoms/button";
|
||||||
|
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
||||||
|
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
|
||||||
|
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
|
||||||
|
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
|
||||||
|
|
||||||
|
interface PlanHeaderProps {
|
||||||
|
plan: InternetPlanCatalogItem;
|
||||||
|
backHref?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanHeader({
|
||||||
|
plan,
|
||||||
|
backHref,
|
||||||
|
backLabel = "Back to Internet Plans",
|
||||||
|
title = "Configure your plan",
|
||||||
|
className = "",
|
||||||
|
}: PlanHeaderProps) {
|
||||||
|
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`text-center mb-8 animate-in fade-in duration-300 ${className}`}>
|
||||||
|
{backHref && (
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={backHref}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
|
className="mb-6 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-5">{title}</h1>
|
||||||
|
<span className="sr-only">
|
||||||
|
{planBaseName}
|
||||||
|
{planDetail ? ` (${planDetail})` : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-card px-6 py-3 rounded-full border border-border shadow-sm text-sm">
|
||||||
|
{plan.internetPlanTier ? (
|
||||||
|
<CardBadge
|
||||||
|
text={plan.internetPlanTier}
|
||||||
|
variant={getTierBadgeVariant(plan.internetPlanTier)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
|
||||||
|
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
|
||||||
|
¥{plan.monthlyPrice.toLocaleString()}/month
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTierBadgeVariant(tier?: string | null): BadgeVariant {
|
||||||
|
switch (tier) {
|
||||||
|
case "Gold":
|
||||||
|
return "gold";
|
||||||
|
case "Platinum":
|
||||||
|
return "platinum";
|
||||||
|
case "Silver":
|
||||||
|
return "silver";
|
||||||
|
case "Recommended":
|
||||||
|
return "recommended";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,7 @@
|
|||||||
import { useEffect, useState, type ReactElement } from "react";
|
import { useEffect, useState, type ReactElement } from "react";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { ProgressSteps } from "@/components/molecules";
|
import { ProgressSteps } from "@/components/molecules";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { ServerIcon } from "@heroicons/react/24/outline";
|
||||||
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
|
||||||
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
|
|
||||||
import { ServerIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
|
||||||
import type {
|
import type {
|
||||||
InternetPlanCatalogItem,
|
InternetPlanCatalogItem,
|
||||||
InternetInstallationCatalogItem,
|
InternetInstallationCatalogItem,
|
||||||
@ -19,8 +16,8 @@ import { InstallationStep } from "./steps/InstallationStep";
|
|||||||
import { AddonsStep } from "./steps/AddonsStep";
|
import { AddonsStep } from "./steps/AddonsStep";
|
||||||
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
||||||
import { useConfigureState } from "./hooks/useConfigureState";
|
import { useConfigureState } from "./hooks/useConfigureState";
|
||||||
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
|
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
|
import { PlanHeader } from "@/features/catalog/components/internet/PlanHeader";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plan: InternetPlanCatalogItem | null;
|
plan: InternetPlanCatalogItem | null;
|
||||||
@ -61,6 +58,7 @@ export function InternetConfigureContainer({
|
|||||||
currentStep,
|
currentStep,
|
||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const shopBasePath = useShopBasePath();
|
||||||
const [renderedStep, setRenderedStep] = useState(currentStep);
|
const [renderedStep, setRenderedStep] = useState(currentStep);
|
||||||
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
|
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
|
||||||
// Use local state ONLY for step validation, step management now in Zustand
|
// Use local state ONLY for step validation, step management now in Zustand
|
||||||
@ -214,7 +212,11 @@ export function InternetConfigureContainer({
|
|||||||
<div className="min-h-[70vh]">
|
<div className="min-h-[70vh]">
|
||||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Plan Header */}
|
{/* Plan Header */}
|
||||||
<PlanHeader plan={plan} />
|
<PlanHeader
|
||||||
|
plan={plan}
|
||||||
|
backHref={`${shopBasePath}/internet`}
|
||||||
|
backLabel="Back to Internet Plans"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
@ -230,60 +232,3 @@ export function InternetConfigureContainer({
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
|
||||||
const shopBasePath = useShopBasePath();
|
|
||||||
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center mb-8 animate-in fade-in duration-300">
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href={`${shopBasePath}/internet`}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
|
||||||
className="mb-6 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Back to Internet Plans
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-5">Configure your plan</h1>
|
|
||||||
<span className="sr-only">
|
|
||||||
{planBaseName}
|
|
||||||
{planDetail ? ` (${planDetail})` : ""}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-card px-6 py-3 rounded-full border border-border shadow-sm text-sm">
|
|
||||||
{plan.internetPlanTier ? (
|
|
||||||
<CardBadge
|
|
||||||
text={plan.internetPlanTier}
|
|
||||||
variant={getTierBadgeVariant(plan.internetPlanTier)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
|
|
||||||
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
|
|
||||||
¥{plan.monthlyPrice.toLocaleString()}/month
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTierBadgeVariant(tier?: string | null): BadgeVariant {
|
|
||||||
switch (tier) {
|
|
||||||
case "Gold":
|
|
||||||
return "gold";
|
|
||||||
case "Platinum":
|
|
||||||
return "platinum";
|
|
||||||
case "Silver":
|
|
||||||
return "silver";
|
|
||||||
case "Recommended":
|
|
||||||
return "recommended";
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -69,7 +69,10 @@ export function InternetPlansContainer() {
|
|||||||
const isPending = eligibilityStatus === "pending";
|
const isPending = eligibilityStatus === "pending";
|
||||||
const isNotRequested = eligibilityStatus === "not_requested";
|
const isNotRequested = eligibilityStatus === "not_requested";
|
||||||
const isIneligible = eligibilityStatus === "ineligible";
|
const isIneligible = eligibilityStatus === "ineligible";
|
||||||
const orderingLocked = isPending || isNotRequested || isIneligible;
|
|
||||||
|
// While loading eligibility, we assume locked to prevent showing unfiltered catalog for a split second
|
||||||
|
const orderingLocked =
|
||||||
|
eligibilityLoading || isPending || isNotRequested || isIneligible || !eligibilityStatus;
|
||||||
const hasServiceAddress = Boolean(
|
const hasServiceAddress = Boolean(
|
||||||
user?.address?.address1 &&
|
user?.address?.address1 &&
|
||||||
user?.address?.city &&
|
user?.address?.city &&
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { CheckIcon, ServerIcon } from "@heroicons/react/24/outline";
|
import { WifiIcon, CheckCircleIcon, ClockIcon, BellIcon } from "@heroicons/react/24/outline";
|
||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
|
||||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public Internet Configure View
|
* Public Internet Configure View
|
||||||
*
|
*
|
||||||
* Shows selected plan information and prompts for authentication via modal.
|
* Generic signup flow for internet availability check.
|
||||||
* Much better UX than redirecting to a full signup page.
|
* Focuses on account creation, not plan details.
|
||||||
*/
|
*/
|
||||||
export function PublicInternetConfigureView() {
|
export function PublicInternetConfigureView() {
|
||||||
const shopBasePath = useShopBasePath();
|
const shopBasePath = useShopBasePath();
|
||||||
@ -23,74 +22,45 @@ export function PublicInternetConfigureView() {
|
|||||||
: "/account/shop/internet?autoEligibilityRequest=1";
|
: "/account/shop/internet?autoEligibilityRequest=1";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
|
||||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
|
||||||
|
|
||||||
<CatalogHero
|
{/* Header */}
|
||||||
title="Check availability for your address"
|
<div className="mt-6 mb-8 text-center">
|
||||||
description="Create an account so we can verify service availability. We'll submit your request automatically."
|
<div className="flex justify-center mb-4">
|
||||||
/>
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 border border-primary/20">
|
||||||
|
<WifiIcon className="h-7 w-7 text-primary" />
|
||||||
<div className="mt-8 space-y-8">
|
|
||||||
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
|
|
||||||
<div className="flex items-start gap-4 mb-6">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
|
|
||||||
<ServerIcon className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<h1 className="text-2xl font-bold text-foreground mb-2">Check Internet Availability</h1>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
<p className="text-muted-foreground">
|
||||||
We'll check availability for your address
|
Create an account to verify service availability at your address.
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This request unlocks the internet plans that match your building type and local
|
|
||||||
infrastructure.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border pt-6 mt-6">
|
{/* Process Steps - Compact */}
|
||||||
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
<div className="mb-8 grid grid-cols-3 gap-3 text-center">
|
||||||
What happens next
|
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
</h4>
|
<CheckCircleIcon className="h-5 w-5 text-success" />
|
||||||
<ul className="space-y-3">
|
<span className="text-xs font-medium text-foreground">Create account</span>
|
||||||
<li className="flex items-start gap-2">
|
</div>
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
<span className="text-sm text-muted-foreground">
|
<ClockIcon className="h-5 w-5 text-info" />
|
||||||
Create your account and confirm your service address.
|
<span className="text-xs font-medium text-foreground">We verify availability</span>
|
||||||
</span>
|
</div>
|
||||||
</li>
|
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
<li className="flex items-start gap-2">
|
<BellIcon className="h-5 w-5 text-primary" />
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
<span className="text-xs font-medium text-foreground">Get notified</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
We submit an availability request to our team.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
You'll be notified when your personalized plans are ready.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Section */}
|
||||||
<InlineAuthSection
|
<InlineAuthSection
|
||||||
title="Ready to get started?"
|
title="Create your account"
|
||||||
description="Create an account so we can verify service availability for your address. We'll submit your request automatically and notify you when it's reviewed."
|
description="We'll verify internet availability at your address and notify you when ready."
|
||||||
redirectTo={redirectTo}
|
redirectTo={redirectTo}
|
||||||
highlights={[
|
|
||||||
{ title: "Verify Availability", description: "Check service at your address" },
|
|
||||||
{ title: "Personalized Plans", description: "See plans tailored to you" },
|
|
||||||
{ title: "Secure Ordering", description: "Complete your order safely" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export function PublicLandingView() {
|
|||||||
<ArrowRightIcon className="h-4 w-4" />
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/link-whmcs"
|
href="/auth/migrate"
|
||||||
className="inline-flex items-center justify-center rounded-xl px-6 py-3 text-sm font-medium border border-border bg-card hover:bg-muted/50 transition-colors"
|
className="inline-flex items-center justify-center rounded-xl px-6 py-3 text-sm font-medium border border-border bg-card hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
Migrate account
|
Migrate account
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export function PublicLandingView() {
|
|||||||
Login to Portal
|
Login to Portal
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/link-whmcs"
|
href="/auth/migrate"
|
||||||
className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
|
className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
|
||||||
>
|
>
|
||||||
Migrate Account
|
Migrate Account
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { logger } from "@/lib/logger";
|
|||||||
const AUTH_ENDPOINTS = [
|
const AUTH_ENDPOINTS = [
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/signup",
|
"/api/auth/signup",
|
||||||
"/api/auth/link-whmcs",
|
"/api/auth/migrate",
|
||||||
"/api/auth/set-password",
|
"/api/auth/set-password",
|
||||||
"/api/auth/reset-password",
|
"/api/auth/reset-password",
|
||||||
"/api/auth/check-password-needed",
|
"/api/auth/check-password-needed",
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page.
|
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 in place and working.
|
3. **Opportunity Lifecycle Fields Exist** - `Portal_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 not ID verification.
|
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification.
|
||||||
|
|
||||||
@ -68,7 +68,7 @@
|
|||||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||||
│ CatalogService │ CheckoutService │ VerificationService │ OrderService │
|
│ CatalogService │ CheckoutService │ VerificationService │ OrderService │
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ OpportunityMatchingService │ OrderOrchestrator │ FulfillmentOrchestrator │
|
│ Opportunity Resolution │ OrderOrchestrator │ FulfillmentOrchestrator │
|
||||||
└───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘
|
└───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
@ -364,7 +364,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
|||||||
4. **Manual Agent Work Required** - Agent checks NTT serviceability
|
4. **Manual Agent Work Required** - Agent checks NTT serviceability
|
||||||
5. Agent updates Account with eligibility result
|
5. Agent updates Account with eligibility result
|
||||||
6. Salesforce Flow sends email notification to customer
|
6. Salesforce Flow sends email notification to customer
|
||||||
7. **Note:** Opportunity is NOT created during eligibility - only at order placement
|
7. **Note:** Opportunity **is created/reused during eligibility** (Stage = Introduction) so the Case can link to it (`Case.OpportunityId`) and the journey can be reused at order placement.
|
||||||
|
|
||||||
**Account Fields Updated:**
|
**Account Fields Updated:**
|
||||||
| Field | Value Set | When |
|
| Field | Value Set | When |
|
||||||
@ -425,14 +425,18 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
|||||||
|
|
||||||
### Opportunity Management Module
|
### Opportunity Management Module
|
||||||
|
|
||||||
**Location:** `apps/bff/src/modules/orders/services/opportunity-matching.service.ts`
|
**Location (current code paths):**
|
||||||
|
|
||||||
|
- `apps/bff/src/modules/catalog/services/internet-catalog.service.ts` (eligibility request creates/reuses Opportunity + Case)
|
||||||
|
- `apps/bff/src/modules/orders/services/order-orchestrator.service.ts` (order placement resolves Opportunity)
|
||||||
|
- `apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts` (Salesforce query/create/update)
|
||||||
|
|
||||||
**Matching Rules:**
|
**Matching Rules:**
|
||||||
| Scenario | Action |
|
| Scenario | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| Order has `opportunityId` | Use it directly |
|
| Order has `opportunityId` | Use it directly |
|
||||||
| Internet order without Opp | Find Introduction/Ready stage or create new |
|
| Internet order without Opp | Find Introduction/Ready stage or create new |
|
||||||
| SIM order without Opp | Find open Opportunity or create new |
|
| SIM order without Opp | Find Introduction/Ready stage or create new |
|
||||||
| VPN order | Always create new Opportunity |
|
| VPN order | Always create new Opportunity |
|
||||||
|
|
||||||
**Stage Transitions by Trigger:**
|
**Stage Transitions by Trigger:**
|
||||||
@ -539,7 +543,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
|||||||
|
|
||||||
- Salesforce Flow automatically sends email to customer when eligibility fields are updated
|
- Salesforce Flow automatically sends email to customer when eligibility fields are updated
|
||||||
- Portal polls Account for eligibility changes (customer sees result on next visit)
|
- Portal polls Account for eligibility changes (customer sees result on next visit)
|
||||||
- **Opportunity is created later at order placement** (not during eligibility check)
|
- Opportunity is created/reused at eligibility request (Stage = Introduction) and then reused at order placement when possible
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -662,7 +666,7 @@ LONG TERM:
|
|||||||
|
|
||||||
**Status:** ✅ Confirmed fields exist:
|
**Status:** ✅ Confirmed fields exist:
|
||||||
|
|
||||||
- `Opportunity_Source__c` - Picklist with portal values
|
- `Portal_Source__c` - Picklist with portal values
|
||||||
- `WHMCS_Service_ID__c` - Number field for WHMCS linking
|
- `WHMCS_Service_ID__c` - Number field for WHMCS linking
|
||||||
|
|
||||||
**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder).
|
**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder).
|
||||||
|
|||||||
@ -21,7 +21,7 @@ This guide describes how eligibility and verification work in the customer porta
|
|||||||
|
|
||||||
1. Customer navigates to `/account/shop/internet`
|
1. Customer navigates to `/account/shop/internet`
|
||||||
2. Customer enters service address and requests eligibility check
|
2. Customer enters service address and requests eligibility check
|
||||||
3. Portal creates a Salesforce Case for agent review
|
3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
|
||||||
4. Agent performs NTT serviceability check (manual process)
|
4. Agent performs NTT serviceability check (manual process)
|
||||||
5. Agent updates Account eligibility fields
|
5. Agent updates Account eligibility fields
|
||||||
6. Salesforce Flow sends email notification to customer
|
6. Salesforce Flow sends email notification to customer
|
||||||
|
|||||||
@ -352,10 +352,10 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
│ │
|
│ │
|
||||||
│ 4. BIDIRECTIONAL LINK COMPLETE │
|
│ 4. BIDIRECTIONAL LINK COMPLETE │
|
||||||
│ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │
|
│ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │
|
||||||
│ └─ WHMCS Service.OpportunityId → Opportunity │
|
│ └─ WHMCS Service.OpportunityId → Opportunity (optional; helpful for ops/debugging) │
|
||||||
│ │
|
│ │
|
||||||
│ 5. CUSTOMER SEES SERVICE PAGE │
|
│ 5. CUSTOMER SEES SERVICE PAGE │
|
||||||
│ └─ Portal queries WHMCS → gets OpportunityId → queries Opp │
|
│ └─ Portal shows active services from WHMCS; lifecycle/cancellation tracking lives on the linked Salesforce Opportunity (via WHMCS_Service_ID__c) │
|
||||||
│ │
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@ -448,15 +448,15 @@ On/After 25th → Must select NEXT month or later
|
|||||||
- [x] Case.OpportunityId (standard lookup)
|
- [x] Case.OpportunityId (standard lookup)
|
||||||
- [x] Order.OpportunityId (standard lookup)
|
- [x] Order.OpportunityId (standard lookup)
|
||||||
|
|
||||||
**New Fields to Create on Opportunity:**
|
**Opportunity Fields Required (Portal writes these):**
|
||||||
|
|
||||||
- [ ] `Portal_Source__c` picklist
|
- [ ] `Portal_Source__c` picklist (used to track how the Opportunity was created)
|
||||||
- [ ] `WHMCS_Service_ID__c` number field
|
- [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations)
|
||||||
|
|
||||||
### WHMCS Admin Tasks
|
### WHMCS Admin Tasks
|
||||||
|
|
||||||
- [ ] Create `OpportunityId` custom field on Services/Hosting
|
- [ ] Create an `OpportunityId` custom field on Services/Hosting (optional but recommended for ops/debugging)
|
||||||
- [ ] Document custom field ID for AddOrder API
|
- [ ] Confirm whether your WHMCS expects `customfields[]` keys by **name** (`OpportunityId`) or by **numeric field id**, and configure accordingly
|
||||||
|
|
||||||
### BFF Development Tasks
|
### BFF Development Tasks
|
||||||
|
|
||||||
@ -466,22 +466,20 @@ On/After 25th → Must select NEXT month or later
|
|||||||
- [x] Field map configuration
|
- [x] Field map configuration
|
||||||
- [x] `SalesforceOpportunityService`
|
- [x] `SalesforceOpportunityService`
|
||||||
- [x] Cancellation deadline helpers (25th rule)
|
- [x] Cancellation deadline helpers (25th rule)
|
||||||
|
- [x] Eligibility request creates/reuses Opportunity (Stage `Introduction`) and links Case (`Case.OpportunityId`)
|
||||||
|
- [x] Order placement reuses Opportunity in `Introduction`/`Ready` (otherwise creates new `Post Processing`)
|
||||||
|
- [x] Fulfillment passes `OpportunityId` to WHMCS and links `WHMCS_Service_ID__c` back to the Opportunity
|
||||||
|
- [x] Cancellation Case creation + Opportunity cancellation field updates (Internet cancellations)
|
||||||
|
|
||||||
**Pending:**
|
**Optional / Future:**
|
||||||
|
|
||||||
- [ ] Register services in Salesforce module
|
- [ ] Read `OpportunityId` back from WHMCS service custom fields (portal currently relies on Salesforce `WHMCS_Service_ID__c` for linking)
|
||||||
- [ ] Integrate Opportunity matching into eligibility flow
|
|
||||||
- [ ] Integrate Opportunity matching into order placement
|
|
||||||
- [ ] Update order provisioning to pass OpportunityId to WHMCS
|
|
||||||
- [ ] Update WHMCS mapper to read OpportunityId custom field
|
|
||||||
- [ ] Create cancellation Case service
|
|
||||||
- [ ] Integrate Case creation into cancellation flow
|
|
||||||
|
|
||||||
### Frontend Tasks
|
### Frontend Tasks
|
||||||
|
|
||||||
- [ ] Order tracking page (from Salesforce Order)
|
- [ ] Order tracking page (from Salesforce Order)
|
||||||
- [ ] Service page with cancellation status
|
- [ ] Service page with cancellation status
|
||||||
- [ ] Cancellation form with 25th deadline logic
|
- [x] Cancellation forms (Internet + SIM) with 25th deadline logic
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -201,12 +201,16 @@ export const OPPORTUNITY_SOURCE = {
|
|||||||
export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE];
|
export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Opportunity Matching Constants
|
// Opportunity Matching / Stage Sets
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stages considered "open" for matching purposes
|
* Stages considered "open" (i.e. not closed) in our lifecycle.
|
||||||
* Opportunities in these stages can be linked to new orders
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* - Do NOT use this list to match a *new* order or eligibility request to an existing Opportunity.
|
||||||
|
* For those flows we intentionally match a much smaller set of early-stage Opportunities.
|
||||||
|
* - Use the `OPPORTUNITY_MATCH_STAGES_*` constants below instead.
|
||||||
*/
|
*/
|
||||||
export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
||||||
OPPORTUNITY_STAGE.INTRODUCTION,
|
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
@ -215,6 +219,27 @@ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
|||||||
OPPORTUNITY_STAGE.ACTIVE,
|
OPPORTUNITY_STAGE.ACTIVE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages eligible for matching during an Internet eligibility request.
|
||||||
|
*
|
||||||
|
* We only ever reuse the initial "Introduction" opportunity; later stages indicate the
|
||||||
|
* customer journey has progressed beyond eligibility.
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY: OpportunityStageValue[] = [
|
||||||
|
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages eligible for matching during order placement.
|
||||||
|
*
|
||||||
|
* If a customer came from eligibility, the Opportunity will usually be in "Introduction" or "Ready".
|
||||||
|
* We must never match an "Active" Opportunity for a new order (that would corrupt lifecycle tracking).
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT: OpportunityStageValue[] = [
|
||||||
|
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
|
OPPORTUNITY_STAGE.READY,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stages that indicate the Opportunity is closed
|
* Stages that indicate the Opportunity is closed
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export {
|
|||||||
type OpportunitySourceValue,
|
type OpportunitySourceValue,
|
||||||
// Matching constants
|
// Matching constants
|
||||||
OPEN_OPPORTUNITY_STAGES,
|
OPEN_OPPORTUNITY_STAGES,
|
||||||
|
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
|
||||||
|
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
|
||||||
CLOSED_OPPORTUNITY_STAGES,
|
CLOSED_OPPORTUNITY_STAGES,
|
||||||
// Deadline constants
|
// Deadline constants
|
||||||
CANCELLATION_DEADLINE_DAY,
|
CANCELLATION_DEADLINE_DAY,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user