feat(eligibility): add address key field for Salesforce Flow duplicate detection

Add Eligibility_Address_Key__c field (postcode:streetAddress format) to Case records
for eligibility check requests. Salesforce Flow will use this field to detect duplicate
requests for the same address within a time period.

Changes:
- Add eligibilityAddressKey to CreateCaseParams and CASE_FIELDS mapping
- Add streetAddress param to EligibilityCheckCaseParams for passing from callers
- Add createAddressKey() helper in WorkflowCaseManager to generate the key
- Update raw.types.ts schema with new field
- Note in internet-eligibility.service.ts that basic flow skips duplicate detection
This commit is contained in:
barsa 2026-01-20 18:53:46 +09:00
parent 04fd0ea233
commit 464f98284a
20 changed files with 819 additions and 101 deletions

View File

@ -399,6 +399,9 @@ export const CASE_FIELDS = {
suppliedEmail: "SuppliedEmail",
suppliedName: "SuppliedName",
suppliedPhone: "SuppliedPhone",
// Eligibility address key for duplicate detection (postcode:streetAddress)
eligibilityAddressKey: "Eligibility_Address_Key__c",
} as const;
export type CaseFieldKey = keyof typeof CASE_FIELDS;

View File

@ -66,6 +66,8 @@ export interface CreateCaseParams {
contactId?: string | undefined;
/** Optional Opportunity ID for workflow cases */
opportunityId?: string | undefined;
/** Eligibility address key for duplicate detection (postcode:streetAddress) */
eligibilityAddressKey?: string | undefined;
}
/**
@ -210,6 +212,11 @@ export class SalesforceCaseService {
casePayload[CASE_FIELDS.opportunityId] = safeOpportunityId;
}
// Add eligibility address key for duplicate detection (if provided)
if (params.eligibilityAddressKey) {
casePayload[CASE_FIELDS.eligibilityAddressKey] = params.eligibilityAddressKey;
}
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };

View File

@ -14,6 +14,7 @@ import type {
WhmcsAddClientResponse,
WhmcsValidateLoginResponse,
WhmcsSsoResponse,
WhmcsGetUsersResponse,
} from "@customer-portal/domain/customer/providers";
import type {
WhmcsGetInvoicesParams,
@ -204,6 +205,17 @@ export class WhmcsConnectionFacade implements OnModuleInit {
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
}
/**
* Get users by email address
* Used to discover if a user account exists in WHMCS
*/
async getUsersByEmail(email: string): Promise<WhmcsGetUsersResponse> {
return this.makeRequest<WhmcsGetUsersResponse>("GetUsers", {
search: email,
limitnum: 10,
});
}
// ==========================================
// INVOICE API METHODS
// ==========================================

View File

@ -3,7 +3,10 @@ import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsConnectionFacade } from "../facades/whmcs.facade.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import { transformWhmcsClientResponse } from "@customer-portal/domain/customer/providers";
import {
transformWhmcsClientResponse,
whmcsGetUsersResponseSchema,
} from "@customer-portal/domain/customer/providers";
import type { WhmcsClient } from "@customer-portal/domain/customer";
/**
@ -74,6 +77,81 @@ export class WhmcsAccountDiscoveryService {
}
}
/**
* Find a user by email address.
* WHMCS users are sub-accounts under clients. If a user exists, we return the associated client ID.
* This handles cases where the email belongs to a user (sub-account) rather than the main client.
*
* @see https://developers.whmcs.com/api-reference/getusers/
*/
async findUserByEmail(email: string): Promise<{ userId: number; clientId: number } | null> {
try {
const rawResponse = await this.connectionService.getUsersByEmail(email);
// Validate response matches expected schema
const response = whmcsGetUsersResponseSchema.parse(rawResponse);
if (!response.users || response.users.length === 0) {
return null;
}
// Find an exact email match (search parameter matches start of email/name, so we need exact match)
const exactMatch = response.users.find(
user => user.email.toLowerCase() === email.toLowerCase()
);
if (!exactMatch) {
return null;
}
// Get the first associated client (users can belong to multiple clients)
const clientAssociation = exactMatch.clients?.[0];
if (!clientAssociation) {
this.logger.warn(`User ${exactMatch.id} found but has no associated clients`);
return null;
}
this.logger.log(
`Discovered user by email: ${email} (user: ${exactMatch.id}, client: ${clientAssociation.id})`
);
return {
userId: Number(exactMatch.id),
clientId: Number(clientAssociation.id),
};
} catch (error) {
this.logger.warn(
{
email,
error: extractErrorMessage(error),
},
"Failed to discover user by email"
);
return null;
}
}
/**
* Find either a client or user by email.
* First checks for a client, then falls back to checking for a user.
* Returns the client data if found via either method.
*/
async findAccountByEmail(email: string): Promise<WhmcsClient | null> {
// First, try to find a client directly
const client = await this.findClientByEmail(email);
if (client) {
return client;
}
// If no client found, check for a user (sub-account)
const user = await this.findUserByEmail(email);
if (user) {
// User found - fetch the associated client
return this.getClientDetailsById(user.clientId);
}
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

View File

@ -22,7 +22,7 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
/** Timestamp when session was marked as used (for one-time operations) */
usedAt?: string;
/** The operation that used this session */
usedFor?: "signup_with_eligibility" | "complete_account";
usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account";
}
/**
@ -134,12 +134,25 @@ export class GetStartedSessionService {
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
if (!sessionData) return false;
const updatedData: SessionData = {
...sessionData,
emailVerified: true,
accountStatus,
...prefillData,
};
// Use buildSessionData to properly filter undefined values
const updatedData = buildSessionData(
{
id: sessionData.id,
email: sessionData.email,
emailVerified: true,
createdAt: sessionData.createdAt,
},
{
accountStatus,
firstName: prefillData?.firstName,
lastName: prefillData?.lastName,
phone: prefillData?.phone,
address: prefillData?.address,
sfAccountId: prefillData?.sfAccountId,
whmcsClientId: prefillData?.whmcsClientId,
eligibilityStatus: prefillData?.eligibilityStatus,
}
);
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl);
@ -222,6 +235,45 @@ export class GetStartedSessionService {
return tokenId;
}
/**
* Retrieve and validate a guest handoff token
*
* Returns the token data if valid, null otherwise.
* The token email must match the verified email for security.
*/
async getGuestHandoffToken(
tokenId: string,
verifiedEmail: string
): Promise<GuestHandoffToken | null> {
const tokenData = await this.cache.get<GuestHandoffToken>(this.buildHandoffKey(tokenId));
if (!tokenData) {
this.logger.debug({ tokenId }, "Guest handoff token not found or expired");
return null;
}
// Security: Ensure the token email matches the verified email
const normalizedVerifiedEmail = verifiedEmail.toLowerCase().trim();
if (tokenData.email !== normalizedVerifiedEmail) {
this.logger.warn(
{ tokenId, tokenEmail: tokenData.email, verifiedEmail: normalizedVerifiedEmail },
"Guest handoff token email mismatch - possible security issue"
);
return null;
}
this.logger.debug({ tokenId, email: normalizedVerifiedEmail }, "Guest handoff token retrieved");
return tokenData;
}
/**
* Delete a guest handoff token after it has been used
*/
async invalidateHandoffToken(tokenId: string): Promise<void> {
await this.cache.del(this.buildHandoffKey(tokenId));
this.logger.debug({ tokenId }, "Guest handoff token invalidated");
}
// ============================================================================
// Session Locking (Idempotency Protection)
// ============================================================================

View File

@ -17,7 +17,12 @@ import {
type GuestEligibilityResponse,
type CompleteAccountRequest,
type SignupWithEligibilityRequest,
type MigrateWhmcsAccountRequest,
} from "@customer-portal/domain/get-started";
import {
getCustomFieldValue,
serializeWhmcsKeyValueMap,
} from "@customer-portal/domain/customer/providers";
import { EmailService } from "@bff/infra/email/email.service.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
@ -27,6 +32,7 @@ import { SalesforceAccountService } from "@bff/integrations/salesforce/services/
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
@ -39,6 +45,7 @@ import { SignupUserCreationService } from "./signup/signup-user-creation.service
import {
PORTAL_SOURCE_NEW_SIGNUP,
PORTAL_SOURCE_INTERNET_ELIGIBILITY,
PORTAL_SOURCE_MIGRATED,
PORTAL_STATUS_ACTIVE,
PORTAL_STATUS_NOT_YET,
} from "@bff/modules/auth/constants/portal.constants.js";
@ -78,6 +85,7 @@ export class GetStartedWorkflowService {
private readonly opportunityResolution: OpportunityResolutionService,
private readonly workflowCases: WorkflowCaseManager,
private readonly whmcsDiscovery: WhmcsAccountDiscoveryService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsSignup: SignupWhmcsService,
private readonly userCreation: SignupUserCreationService,
private readonly tokenService: AuthTokenService,
@ -138,7 +146,7 @@ export class GetStartedWorkflowService {
* @param fingerprint - Optional request fingerprint for session binding check
*/
async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise<VerifyCodeResponse> {
const { email, code } = request;
const { email, code, handoffToken } = request;
const normalizedEmail = email.toLowerCase().trim();
// Verify OTP (with fingerprint check - logs warning if different context)
@ -164,7 +172,39 @@ export class GetStartedWorkflowService {
const accountStatus = await this.determineAccountStatus(normalizedEmail);
// Get prefill data if account exists
const prefill = this.getPrefillData(normalizedEmail, accountStatus);
let prefill = this.getPrefillData(normalizedEmail, accountStatus);
// If handoff token provided (from guest eligibility check), retrieve and apply its data
if (handoffToken) {
const handoffData = await this.sessionService.getGuestHandoffToken(
handoffToken,
normalizedEmail
);
if (handoffData) {
this.logger.debug(
{ email: normalizedEmail, handoffToken },
"Applying handoff token data to session"
);
// Merge handoff data with prefill (handoff takes precedence as it's more recent)
prefill = {
...prefill,
firstName: handoffData.firstName,
lastName: handoffData.lastName,
phone: handoffData.phone ?? prefill?.phone,
address: handoffData.address,
};
// Update account status SF ID if not already set
if (!accountStatus.sfAccountId && handoffData.sfAccountId) {
accountStatus.sfAccountId = handoffData.sfAccountId;
}
// Invalidate the handoff token after use (one-time use)
await this.sessionService.invalidateHandoffToken(handoffToken);
}
}
// Update session with verified status and account info
// Build prefill data object without undefined values (exactOptionalPropertyTypes)
@ -186,7 +226,7 @@ export class GetStartedWorkflowService {
);
this.logger.log(
{ email: normalizedEmail, accountStatus: accountStatus.status },
{ email: normalizedEmail, accountStatus: accountStatus.status, hasHandoff: !!handoffToken },
"Email verified and account status determined"
);
@ -351,25 +391,36 @@ export class GetStartedWorkflowService {
}
const session = sessionResult.session;
const {
password,
phone,
dateOfBirth,
gender,
firstName,
lastName,
address: requestAddress,
} = request;
if (!session.sfAccountId) {
throw new BadRequestException("No Salesforce account found. Please check eligibility first.");
// Determine if this is a new customer (no SF account) or SF-only user
const isNewCustomer = !session.sfAccountId;
// For new customers, name and address must be provided in request
if (isNewCustomer) {
if (!firstName || !lastName) {
throw new BadRequestException("First name and last name are required for new accounts.");
}
if (!requestAddress) {
throw new BadRequestException("Address is required for new accounts.");
}
}
const { password, phone, dateOfBirth, gender } = request;
const lockKey = `complete-account:${session.email}`;
try {
return await this.lockService.withLock(
lockKey,
async () => {
// Verify SF account still exists
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
if (!existingSf || existingSf.id !== session.sfAccountId) {
throw new BadRequestException("Account verification failed. Please start over.");
}
// Check for existing WHMCS client (shouldn't exist for SF-only flow)
// Check for existing WHMCS client
const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email);
if (existingWhmcs) {
throw new ConflictException(
@ -383,18 +434,72 @@ export class GetStartedWorkflowService {
throw new ConflictException("An account already exists. Please log in.");
}
// Resolve SF account (existing or create new)
let sfAccountId: string;
let customerNumber: string | undefined;
if (session.sfAccountId) {
// SF-only user: Verify SF account still exists
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
if (!existingSf || existingSf.id !== session.sfAccountId) {
throw new BadRequestException("Account verification failed. Please start over.");
}
sfAccountId = existingSf.id;
customerNumber = existingSf.accountNumber;
} else {
// New customer: Check if SF account exists or create new one
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
if (existingSf) {
sfAccountId = existingSf.id;
customerNumber = existingSf.accountNumber;
} else {
// Create new SF Account
const { accountId, accountNumber } =
await this.salesforceAccountService.createAccount({
firstName: firstName!,
lastName: lastName!,
email: session.email,
phone: phone ?? "",
portalSource: PORTAL_SOURCE_NEW_SIGNUP,
});
sfAccountId = accountId;
customerNumber = accountNumber;
this.logger.log(
{ email: session.email, sfAccountId },
"Created SF account for new customer"
);
}
}
const passwordHash = await argon2.hash(password);
// Get address from session or SF
const address = session.address;
if (!address || !address.address1 || !address.city || !address.postcode) {
throw new BadRequestException("Address information is incomplete.");
// Use address from request if provided, otherwise from session
const address = requestAddress ?? session.address;
if (
!address ||
!address.address1 ||
!address.city ||
!address.state ||
!address.postcode
) {
throw new BadRequestException(
"Address information is incomplete. Please ensure all required fields (address, city, prefecture, postcode) are provided."
);
}
// Use name from request if provided, otherwise from session
const finalFirstName = firstName ?? session.firstName;
const finalLastName = lastName ?? session.lastName;
if (!finalFirstName || !finalLastName) {
throw new BadRequestException("Name information is missing. Please provide your name.");
}
// Create WHMCS client
const whmcsClient = await this.whmcsSignup.createClient({
firstName: session.firstName!,
lastName: session.lastName!,
firstName: finalFirstName,
lastName: finalLastName,
email: session.email,
password,
phone,
@ -402,11 +507,11 @@ export class GetStartedWorkflowService {
address1: address.address1,
...(address.address2 && { address2: address.address2 }),
city: address.city,
state: address.state ?? "",
state: address.state,
postcode: address.postcode,
country: address.country ?? "Japan",
},
customerNumber: existingSf.accountNumber,
customerNumber: customerNumber ?? null,
dateOfBirth,
gender,
});
@ -416,7 +521,7 @@ export class GetStartedWorkflowService {
email: session.email,
passwordHash,
whmcsClientId: whmcsClient.clientId,
sfAccountId: session.sfAccountId,
sfAccountId,
});
// Fetch fresh user and generate tokens
@ -428,7 +533,7 @@ export class GetStartedWorkflowService {
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
email: session.email,
whmcsClientId: whmcsClient.clientId,
source: "get_started_complete_account",
source: isNewCustomer ? "get_started_new_customer" : "get_started_complete_account",
});
const profile = mapPrismaUserToDomain(freshUser);
@ -438,14 +543,14 @@ export class GetStartedWorkflowService {
});
// Update Salesforce portal flags
await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId);
await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId);
// Invalidate session (fully done)
await this.sessionService.invalidate(request.sessionToken);
this.logger.log(
{ email: session.email, userId },
"Account completed successfully for SF-only user"
{ email: session.email, userId, isNewCustomer },
"Account completed successfully"
);
return {
@ -672,6 +777,225 @@ export class GetStartedWorkflowService {
}
}
// ============================================================================
// WHMCS Migration (Passwordless)
// ============================================================================
/**
* Migrate WHMCS account to portal without legacy password validation
*
* For whmcs_unmapped users after email verification.
* Email verification serves as identity proof - no legacy password needed.
*
* Flow:
* 1. Validate session has WHMCS client ID
* 2. Verify WHMCS client still exists
* 3. Find Salesforce account (by email or customer number)
* 4. Update WHMCS client with new password + DOB + gender
* 5. Create portal user with hashed password
* 6. Create ID mapping
* 7. Update Salesforce portal flags
* 8. Return auth tokens
*
* Security:
* - Session is locked to prevent double submissions
* - Email-level lock prevents concurrent migrations
*/
async migrateWhmcsAccount(request: MigrateWhmcsAccountRequest): Promise<AuthResultInternal> {
// Atomically acquire session lock and mark as used
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"migrate_whmcs_account"
);
if (!sessionResult.success) {
throw new BadRequestException(sessionResult.reason);
}
const session = sessionResult.session;
// Verify session has WHMCS client ID (should be set during verifyCode for whmcs_unmapped)
if (!session.whmcsClientId) {
throw new BadRequestException(
"No WHMCS account found in session. Please verify your email again."
);
}
const { password, dateOfBirth, gender } = request;
const lockKey = `migrate-whmcs:${session.email}`;
try {
return await this.lockService.withLock(
lockKey,
async () => {
// Verify WHMCS client still exists and matches session
const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(session.email);
if (!whmcsClient || whmcsClient.id !== session.whmcsClientId) {
throw new BadRequestException("WHMCS account verification failed. Please start over.");
}
// Check for existing portal user
const existingPortalUser = await this.usersService.findByEmailInternal(session.email);
if (existingPortalUser) {
throw new ConflictException("An account already exists. Please log in.");
}
// Find Salesforce account for mapping (required for ID mapping)
const sfAccount = await this.findSalesforceAccountForMigration(
session.email,
whmcsClient.id
);
if (!sfAccount) {
throw new BadRequestException(
"Unable to find your Salesforce account. Please contact support."
);
}
// Hash password for portal storage
const passwordHash = await argon2.hash(password);
// Update WHMCS client with new password + DOB + gender
await this.updateWhmcsClientForMigration(whmcsClient.id, password, dateOfBirth, gender);
// Create portal user and ID mapping
const { userId } = await this.userCreation.createUserWithMapping({
email: session.email,
passwordHash,
whmcsClientId: whmcsClient.id,
sfAccountId: sfAccount.id,
});
// Fetch fresh user and generate tokens
const freshUser = await this.usersService.findByIdInternal(userId);
if (!freshUser) {
throw new Error("Failed to load created user");
}
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
email: session.email,
whmcsClientId: whmcsClient.id,
source: "whmcs_migration",
});
const profile = mapPrismaUserToDomain(freshUser);
const tokens = await this.tokenService.generateTokenPair({
id: profile.id,
email: profile.email,
});
// Update Salesforce portal flags if SF account exists
if (sfAccount) {
await this.updateSalesforcePortalFlags(
sfAccount.id,
whmcsClient.id,
PORTAL_SOURCE_MIGRATED
);
}
// Invalidate session
await this.sessionService.invalidate(request.sessionToken);
this.logger.log(
{ email: session.email, userId, whmcsClientId: whmcsClient.id },
"WHMCS account migrated successfully"
);
return {
user: profile,
tokens,
};
},
{ ttlMs: 60_000 }
);
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: session.email },
"WHMCS migration failed"
);
throw error;
}
}
/**
* Find Salesforce account for WHMCS migration
* Tries by email first, then by customer number from WHMCS
*/
private async findSalesforceAccountForMigration(
email: string,
whmcsClientId: number
): Promise<{ id: string } | null> {
try {
// First try to find SF account by email
const sfAccount = await this.salesforceAccountService.findByEmail(email);
if (sfAccount) {
return { id: sfAccount.id };
}
// If no SF account found by email, try by customer number from WHMCS
const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerNumber =
getCustomFieldValue(whmcsClient.customfields, "198")?.trim() ??
getCustomFieldValue(whmcsClient.customfields, "Customer Number")?.trim();
if (!customerNumber) {
this.logger.warn(
{ whmcsClientId, email },
"No customer number found in WHMCS for SF lookup"
);
return null;
}
const sfAccountByNumber =
await this.salesforceService.findAccountByCustomerNumber(customerNumber);
if (sfAccountByNumber) {
return { id: sfAccountByNumber.id };
}
this.logger.warn(
{ whmcsClientId, email, customerNumber },
"No Salesforce account found for WHMCS migration"
);
return null;
} catch (error) {
this.logger.warn(
{ error: extractErrorMessage(error), email, whmcsClientId },
"Failed to find Salesforce account for migration"
);
return null;
}
}
/**
* Update WHMCS client with new password and profile data
*/
private async updateWhmcsClientForMigration(
clientId: number,
password: string,
dateOfBirth: string,
gender: string
): Promise<void> {
const dobFieldId = this.config.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.config.get<string>("WHMCS_GENDER_FIELD_ID");
const customfieldsMap: Record<string, string> = {};
if (dobFieldId) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId) customfieldsMap[genderFieldId] = gender;
const updateData: Record<string, unknown> = {
password2: password,
};
if (Object.keys(customfieldsMap).length > 0) {
updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap);
}
await this.whmcsClientService.updateClient(clientId, updateData);
this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data");
}
// ============================================================================
// Private Helpers
// ============================================================================
@ -750,8 +1074,8 @@ export class GetStartedWorkflowService {
}
}
// Check WHMCS client
const whmcsClient = await this.whmcsDiscovery.findClientByEmail(email);
// Check WHMCS client or user (sub-account)
const whmcsClient = await this.whmcsDiscovery.findAccountByEmail(email);
if (whmcsClient) {
// Check if WHMCS is already mapped
const mapping = await this.mappingsService.findByWhmcsClientId(whmcsClient.id);
@ -801,6 +1125,10 @@ export class GetStartedWorkflowService {
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
// Create eligibility case via workflow manager
// Pass streetAddress for duplicate detection (available on BilingualEligibilityAddress)
const streetAddress =
"streetAddress" in address && address.streetAddress ? address.streetAddress : undefined;
await this.workflowCases.notifyEligibilityCheck({
accountId: sfAccountId,
opportunityId,
@ -813,6 +1141,7 @@ export class GetStartedWorkflowService {
postcode: address.postcode,
...(address.country ? { country: address.country } : {}),
},
...(streetAddress ? { streetAddress } : {}),
});
// Update Account eligibility status to Pending
@ -836,7 +1165,10 @@ export class GetStartedWorkflowService {
accountId: string,
whmcsClientId: number,
/** Optional source override - if not provided, source is not updated (preserves existing) */
source?: typeof PORTAL_SOURCE_NEW_SIGNUP | typeof PORTAL_SOURCE_INTERNET_ELIGIBILITY
source?:
| typeof PORTAL_SOURCE_NEW_SIGNUP
| typeof PORTAL_SOURCE_INTERNET_ELIGIBILITY
| typeof PORTAL_SOURCE_MIGRATED
): Promise<void> {
try {
await this.salesforceService.updateAccountPortalFields(accountId, {

View File

@ -16,6 +16,7 @@ import {
completeAccountRequestSchema,
signupWithEligibilityRequestSchema,
signupWithEligibilityResponseSchema,
migrateWhmcsAccountRequestSchema,
} from "@customer-portal/domain/get-started";
import type { User } from "@customer-portal/domain/customer";
@ -32,6 +33,7 @@ class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseS
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
class MigrateWhmcsAccountRequestDto extends createZodDto(migrateWhmcsAccountRequestSchema) {}
interface AuthSuccessResponse {
user: User;
@ -167,4 +169,30 @@ export class GetStartedController {
session: buildSessionInfo(result.authResult.tokens),
};
}
/**
* Migrate WHMCS account to portal (passwordless)
*
* For whmcs_unmapped users after email verification.
* Email verification serves as identity proof - no legacy password needed.
* Creates portal user, syncs password to WHMCS, and returns auth tokens.
*/
@Public()
@Post("migrate-whmcs-account")
@HttpCode(200)
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 900 })
async migrateWhmcsAccount(
@Body() body: MigrateWhmcsAccountRequestDto,
@Res({ passthrough: true }) res: Response
): Promise<AuthSuccessResponse> {
const result = await this.workflow.migrateWhmcsAccount(body);
setAuthCookies(res, result.tokens);
return {
user: result.user,
session: buildSessionInfo(result.tokens),
};
}
}

View File

@ -116,6 +116,7 @@ export class InternetEligibilityService {
if (request.address.postcode) eligibilityAddress["postcode"] = request.address.postcode;
if (request.address.country) eligibilityAddress["country"] = request.address.country;
// Note: streetAddress not passed in this flow (basic address type) - duplicate detection will be skipped
await this.workflowCases.notifyEligibilityCheck({
accountId: sfAccountId,
opportunityId,

View File

@ -92,9 +92,12 @@ export class WorkflowCaseManager {
/**
* Create a case for internet eligibility check request.
* Non-critical: logs warning on failure, does not throw.
*
* Stores an address key (postcode:streetAddress) for Salesforce Flow
* to use for duplicate detection.
*/
async notifyEligibilityCheck(params: EligibilityCheckCaseParams): Promise<void> {
const { accountId, address, opportunityId, opportunityCreated } = params;
const { accountId, address, streetAddress, opportunityId, opportunityCreated } = params;
try {
const opportunityLink = opportunityId
@ -107,6 +110,9 @@ export class WorkflowCaseManager {
const formattedAddress = this.formatAddress(address);
// Create address key for Salesforce Flow duplicate detection
const addressKey = this.createAddressKey(address.postcode, streetAddress);
const description = this.buildDescription([
"Customer requested to check if internet service is available at the following address:",
"",
@ -122,12 +128,15 @@ export class WorkflowCaseManager {
subject: "Internet availability check request (Portal)",
description,
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
// Store address key for Salesforce Flow duplicate detection
eligibilityAddressKey: addressKey ?? undefined,
});
this.logger.log("Created eligibility check case", {
accountIdTail: accountId.slice(-4),
opportunityIdTail: opportunityId?.slice(-4),
opportunityCreated,
hasAddressKey: !!addressKey,
});
} catch (error) {
this.logger.warn("Failed to create eligibility check case", {
@ -328,4 +337,24 @@ export class WorkflowCaseManager {
.filter(Boolean)
.join(", ");
}
/**
* Create an address key for Salesforce Flow duplicate detection.
*
* Format: "{postcode}:{streetAddress}"
* Example: "1060045:1-5-3"
*
* @param postcode - ZIP code (e.g., "1060045")
* @param streetAddress - Street address detail (e.g., "1-5-3")
* @returns Combined address key, or null if either value is missing
*/
private createAddressKey(
postcode: string | undefined | null,
streetAddress: string | undefined | null
): string | null {
if (!postcode || !streetAddress) {
return null;
}
return `${postcode}:${streetAddress}`;
}
}

View File

@ -44,6 +44,12 @@ export interface EligibilityCheckCaseParams extends BaseWorkflowCaseParams {
postcode?: string;
country?: string;
};
/**
* Street address detail from Japan Post form (e.g., "1-5-3").
* Used with postcode for duplicate detection.
* Optional for backward compatibility with existing callers.
*/
streetAddress?: string;
/** Whether a new Opportunity was created for this request */
opportunityCreated: boolean;
}

View File

@ -19,6 +19,7 @@ import {
type CompleteAccountRequest,
type SignupWithEligibilityRequest,
type SignupWithEligibilityResponse,
type MigrateWhmcsAccountRequest,
} from "@customer-portal/domain/get-started";
import {
authResponseSchema,
@ -136,3 +137,20 @@ export function isSignupSuccess(
): response is SignupWithEligibilitySuccessResponse {
return response.success === true && "user" in response && "session" in response;
}
/**
* Migrate WHMCS account to portal (passwordless)
*
* For whmcs_unmapped users after email verification.
* Email verification serves as identity proof - no legacy password needed.
* Creates portal user, syncs password to WHMCS, and returns auth tokens.
*/
export async function migrateWhmcsAccount(
request: MigrateWhmcsAccountRequest
): Promise<AuthResponse> {
const response = await apiClient.POST<AuthResponse>(`${BASE_PATH}/migrate-whmcs-account`, {
body: request,
});
const data = getDataOrThrow(response, "Failed to migrate account");
return authResponseSchema.parse(data);
}

View File

@ -13,6 +13,7 @@ import {
VerificationStep,
AccountStatusStep,
CompleteAccountStep,
MigrateAccountStep,
SuccessStep,
} from "./steps";
@ -21,6 +22,7 @@ const stepComponents: Record<GetStartedStep, React.ComponentType> = {
verification: VerificationStep,
"account-status": AccountStatusStep,
"complete-account": CompleteAccountStep,
"migrate-account": MigrateAccountStep,
success: SuccessStep,
};
@ -41,6 +43,10 @@ const stepTitles: Record<GetStartedStep, { title: string; subtitle: string }> =
title: "Create Your Account",
subtitle: "Just a few more details",
},
"migrate-account": {
title: "Set Up Your Account",
subtitle: "Create your portal password",
},
success: {
title: "Account Created!",
subtitle: "You're all set",

View File

@ -3,14 +3,13 @@
*
* Routes based on account status:
* - portal_exists: Show login form inline (or redirect link in full-page mode)
* - whmcs_unmapped: Show migrate form inline (or redirect link in full-page mode)
* - whmcs_unmapped: Go to migrate-account step (passwordless, email verification = identity proof)
* - sf_unmapped: Go to complete-account step (pre-filled form)
* - new_customer: Go to complete-account step (full signup)
*/
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms";
import {
CheckCircleIcon,
@ -20,16 +19,18 @@ import {
} from "@heroicons/react/24/outline";
import { CheckCircle2 } from "lucide-react";
import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm";
import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import { useGetStartedStore } from "../../../stores/get-started.store";
export function AccountStatusStep() {
const router = useRouter();
const { accountStatus, formData, goToStep, prefill, inline, redirectTo, serviceContext } =
useGetStartedStore();
// Compute effective redirect URL from store state
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
// Compute effective redirect URL from store state (with validation)
const effectiveRedirectTo = getSafeRedirect(
redirectTo || serviceContext?.redirectTo,
"/account/dashboard"
);
// Portal exists - show login form inline or redirect to login page
if (accountStatus === "portal_exists") {
@ -91,71 +92,79 @@ export function AccountStatusStep() {
);
}
// WHMCS exists but not mapped - show migrate form inline or redirect to migrate page
// WHMCS exists but not mapped - go to migrate-account step (passwordless)
// Email verification already proves identity - no legacy password needed
if (accountStatus === "whmcs_unmapped") {
// Inline mode: render migrate form directly
if (inline) {
return (
<div className="space-y-6">
<div className="text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<UserCircleIcon className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2 mt-4">
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
<p className="text-sm text-muted-foreground">
We found an existing billing account with this email. Please verify your password to
link it to your new portal account.
</p>
</div>
</div>
<LinkWhmcsForm
initialEmail={formData.email}
onTransferred={result => {
if (result.needsPasswordSet) {
const params = new URLSearchParams({
email: result.user.email,
redirect: effectiveRedirectTo,
});
router.push(`/auth/set-password?${params.toString()}`);
return;
}
router.push(effectiveRedirectTo);
}}
/>
</div>
);
}
// Full-page mode: redirect to migrate page
const migrateUrl = `/auth/migrate?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
return (
<div className="space-y-6 text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<UserCircleIcon className="h-8 w-8 text-primary" />
<div className="space-y-6">
<div className="text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<UserCircleIcon className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2 mt-4">
<h3 className="text-lg font-semibold text-foreground">
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
</h3>
<p className="text-sm text-muted-foreground">
We found your existing billing account. Set up your new portal password to continue.
</p>
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
<p className="text-sm text-muted-foreground">
We found an existing billing account with this email. Please verify your password to
link it to your new portal account.
</p>
{/* Show what's pre-filled vs what's needed */}
<div className="p-4 rounded-xl bg-muted/50 border border-border text-left space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Your account info:</p>
<ul className="space-y-1.5">
<li className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
<span>Email verified</span>
</li>
{(prefill?.firstName || prefill?.lastName) && (
<li className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
<span>Name on file</span>
</li>
)}
{prefill?.phone && (
<li className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
<span>Phone number on file</span>
</li>
)}
{prefill?.address && (
<li className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
<span>Address on file</span>
</li>
)}
</ul>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">What you&apos;ll add:</p>
<ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<span className="w-4 text-center"></span>
<span>Date of birth</span>
</li>
<li className="flex items-center gap-2">
<span className="w-4 text-center"></span>
<span>New portal password</span>
</li>
</ul>
</div>
</div>
<Button
as="a"
href={migrateUrl}
onClick={() => goToStep("migrate-account")}
className="w-full h-11"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
Link My Account
Continue
</Button>
</div>
);

View File

@ -2,4 +2,5 @@ export { EmailStep } from "./EmailStep";
export { VerificationStep } from "./VerificationStep";
export { AccountStatusStep } from "./AccountStatusStep";
export { CompleteAccountStep } from "./CompleteAccountStep";
export { MigrateAccountStep } from "./MigrateAccountStep";
export { SuccessStep } from "./SuccessStep";

View File

@ -17,6 +17,7 @@ export type GetStartedStep =
| "verification"
| "account-status"
| "complete-account"
| "migrate-account"
| "success";
/**
@ -97,6 +98,7 @@ export interface GetStartedState {
sendVerificationCode: (email: string) => Promise<boolean>;
verifyCode: (code: string) => Promise<AccountStatus | null>;
completeAccount: () => Promise<AuthResponse | null>;
migrateWhmcsAccount: () => Promise<AuthResponse | null>;
// Navigation
goToStep: (step: GetStartedStep) => void;
@ -240,7 +242,7 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
},
completeAccount: async () => {
const { sessionToken, formData } = get();
const { sessionToken, formData, accountStatus } = get();
if (!sessionToken) {
set({ error: "Session expired. Please start over." });
@ -250,6 +252,10 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
set({ loading: true, error: null });
try {
// For new customers, include name and address in request
// For SF-only users, these come from session (via handoff token)
const isNewCustomer = accountStatus === "new_customer";
const result = await api.completeAccount({
sessionToken,
password: formData.password,
@ -258,6 +264,21 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
gender: formData.gender as "male" | "female" | "other",
acceptTerms: formData.acceptTerms,
marketingConsent: formData.marketingConsent,
// Include name and address for new customers (or if provided)
...(isNewCustomer || formData.firstName ? { firstName: formData.firstName } : {}),
...(isNewCustomer || formData.lastName ? { lastName: formData.lastName } : {}),
...(isNewCustomer || formData.address?.address1
? {
address: {
address1: formData.address.address1 || "",
address2: formData.address.address2,
city: formData.address.city || "",
state: formData.address.state || "",
postcode: formData.address.postcode || "",
country: formData.address.country || "Japan",
},
}
: {}),
});
set({ loading: false, step: "success" });
@ -271,18 +292,55 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
}
},
migrateWhmcsAccount: async () => {
const { sessionToken, formData } = get();
if (!sessionToken) {
set({ error: "Session expired. Please start over." });
return null;
}
set({ loading: true, error: null });
try {
const result = await api.migrateWhmcsAccount({
sessionToken,
password: formData.password,
dateOfBirth: formData.dateOfBirth,
gender: formData.gender as "male" | "female" | "other",
acceptTerms: formData.acceptTerms,
marketingConsent: formData.marketingConsent,
});
set({ loading: false, step: "success" });
return result;
} catch (error) {
const message = getErrorMessage(error);
logger.error("Failed to migrate WHMCS account", { error: message });
set({ loading: false, error: message });
return null;
}
},
goToStep: (step: GetStartedStep) => {
set({ step, error: null });
},
goBack: () => {
const { step } = get();
// Both complete-account and migrate-account go back to account-status
const stepOrder: GetStartedStep[] = [
"email",
"verification",
"account-status",
"complete-account",
];
// For migrate-account, go back to account-status directly
if (step === "migrate-account") {
set({ step: "account-status", error: null });
return;
}
const currentIndex = stepOrder.indexOf(step);
const prevStep = stepOrder[currentIndex - 1];
if (currentIndex > 0 && prevStep) {

View File

@ -203,3 +203,38 @@ export const whmcsSsoResponseSchema = z.object({
export type WhmcsSsoResponse = z.infer<typeof whmcsSsoResponseSchema>;
export type WhmcsClientStats = z.infer<typeof whmcsClientStatsSchema>;
// ============================================================================
// WHMCS Get Users Response
// ============================================================================
/**
* WHMCS GetUsers API response schema
* Used to look up users by email during account discovery
*
* @see https://developers.whmcs.com/api-reference/getusers/
*/
export const whmcsUserClientAssociationSchema = z.object({
id: numberLike,
isOwner: z.boolean().optional(),
});
export const whmcsGetUsersUserSchema = z.object({
id: numberLike,
firstname: z.string().optional(),
lastname: z.string().optional(),
email: z.string(),
datecreated: z.string().optional(),
validationdata: z.string().optional(),
clients: z.array(whmcsUserClientAssociationSchema).optional().default([]),
});
export const whmcsGetUsersResponseSchema = z.object({
totalresults: numberLike,
startnumber: numberLike.optional(),
numreturned: numberLike.optional(),
users: z.array(whmcsGetUsersUserSchema).optional().default([]),
});
export type WhmcsGetUsersUser = z.infer<typeof whmcsGetUsersUserSchema>;
export type WhmcsGetUsersResponse = z.infer<typeof whmcsGetUsersResponseSchema>;

View File

@ -23,6 +23,7 @@ import type {
completeAccountRequestSchema,
signupWithEligibilityRequestSchema,
signupWithEligibilityResponseSchema,
migrateWhmcsAccountRequestSchema,
getStartedSessionSchema,
} from "./schema.js";
@ -90,6 +91,7 @@ export type BilingualEligibilityAddress = z.infer<typeof bilingualEligibilityAdd
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
export type SignupWithEligibilityRequest = z.infer<typeof signupWithEligibilityRequestSchema>;
export type MigrateWhmcsAccountRequest = z.infer<typeof migrateWhmcsAccountRequestSchema>;
// ============================================================================
// Response Types

View File

@ -31,6 +31,7 @@ export {
type CompleteAccountRequest,
type SignupWithEligibilityRequest,
type SignupWithEligibilityResponse,
type MigrateWhmcsAccountRequest,
type GetStartedSession,
type GetStartedError,
} from "./contract.js";
@ -57,6 +58,8 @@ export {
// Signup with eligibility schemas (full inline signup)
signupWithEligibilityRequestSchema,
signupWithEligibilityResponseSchema,
// WHMCS migration schema (passwordless migration)
migrateWhmcsAccountRequestSchema,
// Session schema
getStartedSessionSchema,
} from "./schema.js";

View File

@ -199,12 +199,21 @@ export const guestHandoffTokenSchema = z.object({
// ============================================================================
/**
* Request to complete account for SF-only users
* Creates WHMCS client and Portal user, links to existing SF Account
* Request to complete account for SF-only users or new customers
* Creates WHMCS client and Portal user, links to existing SF Account (if any)
*
* For SF-only users: name/address comes from session (prefilled from handoff token)
* For new customers: name/address must be provided in the request
*/
export const completeAccountRequestSchema = z.object({
/** Session token from verified email */
sessionToken: z.string().min(1, "Session token is required"),
/** Customer first name (required for new customers, optional for SF-only) */
firstName: nameSchema.optional(),
/** Customer last name (required for new customers, optional for SF-only) */
lastName: nameSchema.optional(),
/** Address (required for new customers, optional for SF-only who have it in session) */
address: addressFormSchema.optional(),
/** Password for the new portal account */
password: passwordSchema,
/** Phone number (may be pre-filled from SF) */
@ -267,6 +276,32 @@ export const signupWithEligibilityResponseSchema = z.object({
eligibilityRequestId: z.string().optional(),
});
// ============================================================================
// WHMCS Migration Schema (Passwordless Migration)
// ============================================================================
/**
* Request to migrate WHMCS account to portal without legacy password
* For whmcs_unmapped users after email verification
* Creates portal user and syncs password to WHMCS
*/
export const migrateWhmcsAccountRequestSchema = z.object({
/** Session token from verified email */
sessionToken: z.string().min(1, "Session token is required"),
/** Password for the new portal account (will also sync to WHMCS) */
password: passwordSchema,
/** Date of birth */
dateOfBirth: isoDateOnlySchema,
/** Gender */
gender: genderEnum,
/** Accept terms of service */
acceptTerms: z.boolean().refine(val => val === true, {
message: "You must accept the terms of service",
}),
/** Marketing consent */
marketingConsent: z.boolean().optional(),
});
// ============================================================================
// Session Schema
// ============================================================================

View File

@ -133,6 +133,9 @@ export const salesforceCaseRecordSchema = z.object({
Department__c: z.string().nullable().optional(), // Picklist
Comment__c: z.string().nullable().optional(), // Long Text Area(32768)
Notes__c: z.string().nullable().optional(), // Long Text Area(32768)
// Eligibility address key for duplicate detection (postcode:streetAddress)
Eligibility_Address_Key__c: z.string().nullable().optional(), // Text(50)
});
export type SalesforceCaseRecord = z.infer<typeof salesforceCaseRecordSchema>;