Enhance user profile management and signup process

- Added support for custom fields in WHMCS, including customer number, gender, and date of birth, to the environment validation schema.
- Updated the signup workflow to handle new fields for date of birth and gender, ensuring they are included in the client creation process.
- Implemented email update functionality in the user profile service, allowing users to change their email while ensuring uniqueness across the portal.
- Enhanced the profile edit form to include fields for date of birth and gender, with appropriate validation.
- Updated the UI to reflect changes in profile management, ensuring users can view and edit their information seamlessly.
- Improved error handling and validation for user profile updates, ensuring a smoother user experience.
This commit is contained in:
barsa 2025-12-15 17:29:28 +09:00
parent 0d43e0d0b9
commit 545e62b8a1
19 changed files with 987 additions and 356 deletions

View File

@ -62,6 +62,11 @@ export const envSchema = z.object({
WHMCS_DEV_BASE_URL: z.string().url().optional(), WHMCS_DEV_BASE_URL: z.string().url().optional(),
WHMCS_DEV_API_IDENTIFIER: z.string().optional(), WHMCS_DEV_API_IDENTIFIER: z.string().optional(),
WHMCS_DEV_API_SECRET: z.string().optional(), WHMCS_DEV_API_SECRET: z.string().optional(),
// WHMCS Client custom field IDs (as strings)
// Custom fields are sent via AddClient.customfields (base64 encoded serialized array).
WHMCS_CUSTOMER_NUMBER_FIELD_ID: z.string().default("198"),
WHMCS_GENDER_FIELD_ID: z.string().default("200"),
WHMCS_DOB_FIELD_ID: z.string().default("201"),
SF_LOGIN_URL: z.string().url().optional(), SF_LOGIN_URL: z.string().url().optional(),
SF_USERNAME: z.string().optional(), SF_USERNAME: z.string().optional(),

View File

@ -23,6 +23,7 @@ import {
type SignupRequest, type SignupRequest,
type ValidateSignupRequest, type ValidateSignupRequest,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import { CacheService } from "@bff/infra/cache/cache.service.js"; import { CacheService } from "@bff/infra/cache/cache.service.js";
@ -224,18 +225,17 @@ export class SignupWorkflowService {
} }
const customerNumberFieldId = this.configService.get<string>( const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID", "WHMCS_CUSTOMER_NUMBER_FIELD_ID"
"198"
); );
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID"); const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID"); const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID"); const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfields: Record<string, string> = {}; const customfieldsMap: Record<string, string> = {};
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber; if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber;
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth; if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfields[genderFieldId] = gender; if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality; if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
if ( if (
!address?.address1 || !address?.address1 ||
@ -268,7 +268,8 @@ export class SignupWorkflowService {
postcode: address.postcode, postcode: address.postcode,
country: address.country, country: address.country,
password2: password, password2: password,
customfields, customfields:
CustomerProviders.Whmcs.serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
}); });
this.logger.log("WHMCS client created successfully", { this.logger.log("WHMCS client created successfully", {

View File

@ -62,4 +62,17 @@ export class UserAuthRepository {
throw new BadRequestException(`Unable to update user auth state: ${getErrorMessage(error)}`); throw new BadRequestException(`Unable to update user auth state: ${getErrorMessage(error)}`);
} }
} }
async updateEmail(id: string, email: string): Promise<void> {
const validId = validateUuidV4OrThrow(id);
const normalized = normalizeAndValidateEmail(email);
try {
await this.prisma.user.update({
where: { id: validId },
data: { email: normalized },
});
} catch (error) {
throw new BadRequestException(`Unable to update user email: ${getErrorMessage(error)}`);
}
}
} }

View File

@ -1,4 +1,11 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import {
Injectable,
Inject,
NotFoundException,
BadRequestException,
ConflictException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
@ -30,6 +37,7 @@ export class UserProfileService {
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -112,12 +120,39 @@ export class UserProfileService {
const parsed = updateCustomerProfileRequestSchema.parse(update); const parsed = updateCustomerProfileRequestSchema.parse(update);
try { try {
// Explicitly disallow name changes from portal
if (parsed.firstname !== undefined || parsed.lastname !== undefined) {
throw new BadRequestException("Name cannot be changed from the portal.");
}
const mapping = await this.mappingsService.findByUserId(validId); const mapping = await this.mappingsService.findByUserId(validId);
if (!mapping) { if (!mapping) {
throw new NotFoundException("User mapping not found"); throw new NotFoundException("User mapping not found");
} }
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed); // Email changes must update both Portal DB and WHMCS, and must be unique in Portal.
if (parsed.email) {
const currentUser = await this.userAuthRepository.findById(validId);
if (!currentUser) {
throw new NotFoundException("User not found");
}
const newEmail = parsed.email;
const existing = await this.userAuthRepository.findByEmail(newEmail);
if (existing && existing.id !== validId) {
throw new ConflictException("That email address is already in use.");
}
// Update WHMCS first (source of truth for billing profile), then update Portal DB.
await this.whmcsService.updateClient(mapping.whmcsClientId, { email: newEmail });
await this.userAuthRepository.updateEmail(validId, newEmail);
}
// Allow phone/company/language updates through to WHMCS
const { email: _email, firstname: _fn, lastname: _ln, ...whmcsUpdate } = parsed;
if (Object.keys(whmcsUpdate).length > 0) {
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate);
}
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
@ -381,7 +416,39 @@ export class UserProfileService {
try { try {
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
return combineToUser(userAuth, whmcsClient); const base = combineToUser(userAuth, whmcsClient);
// Portal-visible identifiers (read-only). These are stored in WHMCS custom fields.
const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
"198"
);
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const rawSfNumber = customerNumberFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(
whmcsClient.customfields,
customerNumberFieldId
)
: undefined;
const rawDob = dobFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(whmcsClient.customfields, dobFieldId)
: undefined;
const rawGender = genderFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(whmcsClient.customfields, genderFieldId)
: undefined;
const sfNumber = rawSfNumber?.trim() ? rawSfNumber.trim() : null;
const dateOfBirth = rawDob?.trim() ? rawDob.trim() : null;
const gender = rawGender?.trim() ? rawGender.trim() : null;
return {
...base,
sfNumber,
dateOfBirth,
gender,
};
} catch (error) { } catch (error) {
this.logger.error("Failed to fetch client profile from WHMCS", { this.logger.error("Failed to fetch client profile from WHMCS", {
error: getErrorMessage(error), error: getErrorMessage(error),

View File

@ -13,9 +13,18 @@ import { useZodForm } from "@/hooks/useZodForm";
export function useProfileEdit(initial: ProfileEditFormData) { export function useProfileEdit(initial: ProfileEditFormData) {
const handleSave = useCallback(async (formData: ProfileEditFormData) => { const handleSave = useCallback(async (formData: ProfileEditFormData) => {
const previousEmail = useAuthStore.getState().user?.email ?? null;
const requestData: UpdateCustomerProfileRequest = profileFormToRequest(formData); const requestData: UpdateCustomerProfileRequest = profileFormToRequest(formData);
const updated = await accountService.updateProfile(requestData); const updated = await accountService.updateProfile(requestData);
// If email changed, refresh the session immediately to avoid access-token email mismatch
// triggering an automatic logout (auth guard compares token email vs DB email).
if (requestData.email && previousEmail && requestData.email !== previousEmail) {
await useAuthStore.getState().refreshSession();
// refreshSession already updates user+session in the store.
return;
}
useAuthStore.setState(state => ({ useAuthStore.setState(state => ({
...state, ...state,
user: state.user ? { ...state.user, ...updated } : state.user, user: state.user ? { ...state.user, ...updated } : state.user,

View File

@ -26,8 +26,7 @@ export default function ProfileContainer() {
const hasLoadedRef = useRef(false); const hasLoadedRef = useRef(false);
const profile = useProfileEdit({ const profile = useProfileEdit({
firstname: user?.firstname || "", email: user?.email || "",
lastname: user?.lastname || "",
phonenumber: user?.phonenumber || "", phonenumber: user?.phonenumber || "",
}); });
@ -67,16 +66,14 @@ export default function ProfileContainer() {
address.setValue("phoneCountryCode", addr.phoneCountryCode ?? ""); address.setValue("phoneCountryCode", addr.phoneCountryCode ?? "");
} }
if (prof) { if (prof) {
profile.setValue("firstname", prof.firstname || ""); profile.setValue("email", prof.email || "");
profile.setValue("lastname", prof.lastname || "");
profile.setValue("phonenumber", prof.phonenumber || ""); profile.setValue("phonenumber", prof.phonenumber || "");
useAuthStore.setState(state => ({ useAuthStore.setState(state => ({
...state, ...state,
user: state.user user: state.user
? { ? {
...state.user, ...state.user,
firstname: prof.firstname || state.user.firstname, email: prof.email || state.user.email,
lastname: prof.lastname || state.user.lastname,
phonenumber: prof.phonenumber || state.user.phonenumber, phonenumber: prof.phonenumber || state.user.phonenumber,
} }
: (prof as unknown as typeof state.user), : (prof as unknown as typeof state.user),
@ -194,44 +191,73 @@ export default function ProfileContainer() {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label> <label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
{editingProfile ? ( <div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<input <p className="text-base text-gray-900 font-medium">
type="text"
value={profile.values.firstname}
onChange={e => profile.setValue("firstname", e.target.value)}
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
) : (
<p className="text-base text-gray-900 py-2">
{user?.firstname || <span className="text-gray-500 italic">Not provided</span>} {user?.firstname || <span className="text-gray-500 italic">Not provided</span>}
</p> </p>
)} <p className="text-xs text-gray-500 mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Last Name</label> <label className="block text-sm font-medium text-gray-700 mb-2">Last Name</label>
{editingProfile ? ( <div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<input <p className="text-base text-gray-900 font-medium">
type="text"
value={profile.values.lastname}
onChange={e => profile.setValue("lastname", e.target.value)}
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
) : (
<p className="text-base text-gray-900 py-2">
{user?.lastname || <span className="text-gray-500 italic">Not provided</span>} {user?.lastname || <span className="text-gray-500 italic">Not provided</span>}
</p> </p>
)} <p className="text-xs text-gray-500 mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Email Address Email Address
</label> </label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200"> {editingProfile ? (
<div className="flex items-center justify-between"> <input
<p className="text-base text-gray-900 font-medium">{user?.email}</p> type="email"
value={profile.values.email}
onChange={e => profile.setValue("email", e.target.value)}
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
) : (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between">
<p className="text-base text-gray-900 font-medium">{user?.email}</p>
</div>
<p className="text-xs text-gray-500 mt-2">
Email can be updated from the portal.
</p>
</div> </div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Customer Number
</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.sfNumber || <span className="text-gray-500 italic">Not available</span>}
</p>
<p className="text-xs text-gray-500 mt-2">Customer number is read-only.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date of Birth
</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.dateOfBirth || (
<span className="text-gray-500 italic">Not provided</span>
)}
</p>
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
Email cannot be changed from the portal. Date of birth is stored in billing profile.
</p> </p>
</div> </div>
</div> </div>
@ -253,6 +279,16 @@ export default function ProfileContainer() {
</p> </p>
)} )}
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Gender</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.gender || <span className="text-gray-500 italic">Not provided</span>}
</p>
<p className="text-xs text-gray-500 mt-2">Gender is stored in billing profile.</p>
</div>
</div>
</div> </div>
{editingProfile && ( {editingProfile && (

View File

@ -9,10 +9,7 @@ import { useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { ErrorMessage } from "@/components/atoms"; import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth"; import { useSignup } from "../../hooks/use-auth";
import { import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
signupInputSchema,
buildSignupRequest,
} from "@customer-portal/domain/auth";
import { addressFormSchema } from "@customer-portal/domain/customer"; import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/hooks/useZodForm";
import { z } from "zod"; import { z } from "zod";
@ -32,18 +29,16 @@ import { ReviewStep } from "./steps/ReviewStep";
*/ */
const signupFormBaseSchema = signupInputSchema.extend({ const signupFormBaseSchema = signupInputSchema.extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
phoneCountryCode: z phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
.string()
.regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
address: addressFormSchema, address: addressFormSchema,
}); });
const signupFormSchema = signupFormBaseSchema const signupFormSchema = signupFormBaseSchema
.refine((data) => data.acceptTerms === true, { .refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions", message: "You must accept the terms and conditions",
path: ["acceptTerms"], path: ["acceptTerms"],
}) })
.refine((data) => data.password === data.confirmPassword, { .refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match", message: "Passwords do not match",
path: ["confirmPassword"], path: ["confirmPassword"],
}); });
@ -79,10 +74,7 @@ const STEPS = [
}, },
] as const; ] as const;
const STEP_FIELD_KEYS: Record< const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
(typeof STEPS)[number]["key"],
Array<keyof SignupFormData>
> = {
account: [ account: [
"sfNumber", "sfNumber",
"firstName", "firstName",
@ -90,16 +82,15 @@ const STEP_FIELD_KEYS: Record<
"email", "email",
"phone", "phone",
"phoneCountryCode", "phoneCountryCode",
"dateOfBirth",
"gender",
], ],
address: ["address"], address: ["address"],
password: ["password", "confirmPassword"], password: ["password", "confirmPassword"],
review: ["acceptTerms"], review: ["acceptTerms"],
}; };
const STEP_VALIDATION_SCHEMAS: Record< const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
(typeof STEPS)[number]["key"],
z.ZodTypeAny | undefined
> = {
account: signupFormBaseSchema.pick({ account: signupFormBaseSchema.pick({
sfNumber: true, sfNumber: true,
firstName: true, firstName: true,
@ -107,6 +98,8 @@ const STEP_VALIDATION_SCHEMAS: Record<
email: true, email: true,
phone: true, phone: true,
phoneCountryCode: true, phoneCountryCode: true,
dateOfBirth: true,
gender: true,
}), }),
address: signupFormBaseSchema.pick({ address: signupFormBaseSchema.pick({
address: true, address: true,
@ -116,7 +109,7 @@ const STEP_VALIDATION_SCHEMAS: Record<
password: true, password: true,
confirmPassword: true, confirmPassword: true,
}) })
.refine((data) => data.password === data.confirmPassword, { .refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match", message: "Passwords do not match",
path: ["confirmPassword"], path: ["confirmPassword"],
}), }),
@ -124,17 +117,13 @@ const STEP_VALIDATION_SCHEMAS: Record<
.pick({ .pick({
acceptTerms: true, acceptTerms: true,
}) })
.refine((data) => data.acceptTerms === true, { .refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions", message: "You must accept the terms and conditions",
path: ["acceptTerms"], path: ["acceptTerms"],
}), }),
}; };
export function SignupForm({ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
onSuccess,
onError,
className = "",
}: SignupFormProps) {
const { signup, loading, error, clearError } = useSignup(); const { signup, loading, error, clearError } = useSignup();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
@ -148,6 +137,8 @@ export function SignupForm({
phone: "", phone: "",
phoneCountryCode: "+81", phoneCountryCode: "+81",
company: "", company: "",
dateOfBirth: undefined,
gender: undefined,
address: { address: {
address1: "", address1: "",
address2: "", address2: "",
@ -162,7 +153,7 @@ export function SignupForm({
acceptTerms: false, acceptTerms: false,
marketingConsent: false, marketingConsent: false,
}, },
onSubmit: async (data) => { onSubmit: async data => {
clearError(); clearError();
try { try {
// Combine country code + phone for WHMCS format: +CC.NNNNNNNN // Combine country code + phone for WHMCS format: +CC.NNNNNNNN
@ -174,6 +165,8 @@ export function SignupForm({
const request = buildSignupRequest({ const request = buildSignupRequest({
...data, ...data,
phone: formattedPhone, phone: formattedPhone,
dateOfBirth: data.dateOfBirth || undefined,
gender: data.gender || undefined,
address: { address: {
...data.address, ...data.address,
country: "JP", country: "JP",
@ -208,7 +201,7 @@ export function SignupForm({
return; return;
} }
const fields = STEP_FIELD_KEYS[stepKey] ?? []; const fields = STEP_FIELD_KEYS[stepKey] ?? [];
fields.forEach((field) => setFormTouchedField(field)); fields.forEach(field => setFormTouchedField(field));
}, },
[setFormTouchedField] [setFormTouchedField]
); );
@ -240,11 +233,11 @@ export function SignupForm({
return; return;
} }
setStep((s) => Math.min(s + 1, STEPS.length - 1)); setStep(s => Math.min(s + 1, STEPS.length - 1));
}, [handleSubmit, isLastStep, isStepValid, markStepTouched, step]); }, [handleSubmit, isLastStep, isStepValid, markStepTouched, step]);
const handlePrevious = useCallback(() => { const handlePrevious = useCallback(() => {
setStep((s) => Math.max(0, s - 1)); setStep(s => Math.max(0, s - 1));
}, []); }, []);
// Wrap form methods to have generic types for step components // Wrap form methods to have generic types for step components
@ -254,8 +247,7 @@ export function SignupForm({
touched, touched,
setValue: (field: string, value: unknown) => setValue: (field: string, value: unknown) =>
setFormValue(field as keyof SignupFormData, value as never), setFormValue(field as keyof SignupFormData, value as never),
setTouchedField: (field: string) => setTouchedField: (field: string) => setFormTouchedField(field as keyof SignupFormData),
setFormTouchedField(field as keyof SignupFormData),
}; };
const stepContent = [ const stepContent = [
@ -284,9 +276,7 @@ export function SignupForm({
/> />
{error && ( {error && (
<ErrorMessage className="mt-4 text-center p-3 bg-red-50 rounded-lg"> <ErrorMessage className="mt-4 text-center p-3 bg-red-50 rounded-lg">{error}</ErrorMessage>
{error}
</ErrorMessage>
)} )}
<div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-3"> <div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-3">

View File

@ -17,6 +17,8 @@ interface AccountStepProps {
phone: string; phone: string;
phoneCountryCode: string; phoneCountryCode: string;
company?: string; company?: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
}; };
errors: Record<string, string | undefined>; errors: Record<string, string | undefined>;
touched: Record<string, boolean | undefined>; touched: Record<string, boolean | undefined>;
@ -41,7 +43,7 @@ export function AccountStep({ form }: AccountStepProps) {
> >
<Input <Input
value={values.sfNumber} value={values.sfNumber}
onChange={(e) => setValue("sfNumber", e.target.value)} onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")} onBlur={() => setTouchedField("sfNumber")}
placeholder="e.g., AST-123456" placeholder="e.g., AST-123456"
className="bg-white" className="bg-white"
@ -56,7 +58,7 @@ export function AccountStep({ form }: AccountStepProps) {
<Input <Input
name="given-name" name="given-name"
value={values.firstName} value={values.firstName}
onChange={(e) => setValue("firstName", e.target.value)} onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")} onBlur={() => setTouchedField("firstName")}
placeholder="Taro" placeholder="Taro"
autoComplete="given-name" autoComplete="given-name"
@ -66,7 +68,7 @@ export function AccountStep({ form }: AccountStepProps) {
<Input <Input
name="family-name" name="family-name"
value={values.lastName} value={values.lastName}
onChange={(e) => setValue("lastName", e.target.value)} onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")} onBlur={() => setTouchedField("lastName")}
placeholder="Yamada" placeholder="Yamada"
autoComplete="family-name" autoComplete="family-name"
@ -80,7 +82,7 @@ export function AccountStep({ form }: AccountStepProps) {
name="email" name="email"
type="email" type="email"
value={values.email} value={values.email}
onChange={(e) => setValue("email", e.target.value)} onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")} onBlur={() => setTouchedField("email")}
placeholder="taro.yamada@example.com" placeholder="taro.yamada@example.com"
autoComplete="email" autoComplete="email"
@ -98,7 +100,7 @@ export function AccountStep({ form }: AccountStepProps) {
name="tel-country-code" name="tel-country-code"
type="tel" type="tel"
value={values.phoneCountryCode} value={values.phoneCountryCode}
onChange={(e) => { onChange={e => {
// Allow + and digits only, max 5 chars // Allow + and digits only, max 5 chars
let val = e.target.value.replace(/[^\d+]/g, ""); let val = e.target.value.replace(/[^\d+]/g, "");
if (!val.startsWith("+")) val = "+" + val.replace(/\+/g, ""); if (!val.startsWith("+")) val = "+" + val.replace(/\+/g, "");
@ -114,7 +116,7 @@ export function AccountStep({ form }: AccountStepProps) {
name="tel-national" name="tel-national"
type="tel" type="tel"
value={values.phone} value={values.phone}
onChange={(e) => { onChange={e => {
// Only allow digits // Only allow digits
const cleaned = e.target.value.replace(/\D/g, ""); const cleaned = e.target.value.replace(/\D/g, "");
setValue("phone", cleaned); setValue("phone", cleaned);
@ -127,12 +129,50 @@ export function AccountStep({ form }: AccountStepProps) {
</div> </div>
</FormField> </FormField>
{/* DOB + Gender (Optional WHMCS custom fields) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
<Input
name="bday"
type="date"
value={values.dateOfBirth ?? ""}
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
onBlur={() => setTouchedField("dateOfBirth")}
autoComplete="bday"
/>
</FormField>
<FormField label="Gender" error={getError("gender")} helperText="Optional">
<select
name="sex"
value={values.gender ?? ""}
onChange={e => setValue("gender", e.target.value || undefined)}
onBlur={() => setTouchedField("gender")}
className={[
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm",
"ring-offset-background placeholder:text-gray-500 focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
getError("gender")
? "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2"
: "",
].join(" ")}
aria-invalid={Boolean(getError("gender")) || undefined}
>
<option value="">Select</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</FormField>
</div>
{/* Company (Optional) */} {/* Company (Optional) */}
<FormField label="Company" error={getError("company")} helperText="Optional"> <FormField label="Company" error={getError("company")} helperText="Optional">
<Input <Input
name="organization" name="organization"
value={values.company ?? ""} value={values.company ?? ""}
onChange={(e) => setValue("company", e.target.value)} onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")} onBlur={() => setTouchedField("company")}
placeholder="Company name" placeholder="Company name"
autoComplete="organization" autoComplete="organization"

View File

@ -70,6 +70,8 @@ interface ReviewStepProps {
phoneCountryCode: string; phoneCountryCode: string;
sfNumber: string; sfNumber: string;
company?: string; company?: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
address: { address: {
address1: string; address1: string;
address2?: string; address2?: string;
@ -140,6 +142,18 @@ export function ReviewStep({ form }: ReviewStepProps) {
<dd className="text-gray-900 font-medium">{values.company}</dd> <dd className="text-gray-900 font-medium">{values.company}</dd>
</div> </div>
)} )}
{values.dateOfBirth && (
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Date of Birth</dt>
<dd className="text-gray-900 font-medium">{values.dateOfBirth}</dd>
</div>
)}
{values.gender && (
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Gender</dt>
<dd className="text-gray-900 font-medium">{values.gender}</dd>
</div>
)}
</dl> </dl>
</div> </div>

Binary file not shown.

View File

@ -1,229 +1,639 @@
# Customer Portal Complete Guide # Customer Portal Technical Operations Guide
This document gives a plain-language walkthrough of how the portal works end to end: what systems are involved, what data lives where, how each feature behaves, how caching keeps things fast, and what happens when something goes wrong. A comprehensive guide for supervisors explaining how the Customer Portal integrates with WHMCS, Salesforce, and other systems.
## Systems and Roles (Who Owns What) ---
- Portal UI (Next.js) + BFF API (NestJS): handles all customer traffic and orchestrates calls to other systems. ## Contents
- Postgres: stores portal users and the cross-system mapping `user_id ↔ whmcs_client_id ↔ sf_account_id`.
- Redis: cache for faster reads; all keys are scoped per user to avoid data mix-ups.
- WHMCS: system of record for billing (clients, addresses, invoices, payment methods, subscriptions, currencies).
- Salesforce: system of record for CRM (accounts/contacts), product catalog/pricebook, orders, and support cases.
- Freebit: SIM provisioning and management (activation, plan changes, voice features, data top-ups, cancellation).
- SFTP (fs.mvno.net): call and SMS detail CSV files for SIM usage history.
## Data Ownership at a Glance 1. [System Architecture](#system-architecture)
2. [Data Ownership and Flow](#data-ownership-and-flow)
3. [Account Creation and Linking](#account-creation-and-linking)
4. [Profile and Address Management](#profile-and-address-management)
5. [Password Management](#password-management)
6. [Product Catalog and Eligibility](#product-catalog-and-eligibility)
7. [Order Creation](#order-creation)
8. [Order Fulfillment and Provisioning](#order-fulfillment-and-provisioning)
9. [Billing and Payments](#billing-and-payments)
10. [Subscriptions and Services](#subscriptions-and-services)
11. [SIM Management](#sim-management)
12. [Support Cases](#support-cases)
13. [Dashboard and Summary Data](#dashboard-and-summary-data)
14. [Realtime Events](#realtime-events)
15. [Caching Strategy](#caching-strategy)
16. [Error Handling](#error-handling)
17. [Rate Limiting and Security](#rate-limiting-and-security)
- Identity/session: Portal DB (hashed passwords; no WHMCS/Salesforce credentials stored). ---
- Billing profile & addresses: WHMCS is authoritative; portal writes changes back there.
- Orders & statuses: Salesforce is authoritative; WHMCS gets the billing copy during fulfillment.
- Support cases: Salesforce (portal only shows cases for the mapped account).
## Where to Look (by task) ## System Architecture
- Billing data (clients, invoices, payments, subscriptions, currencies): WHMCS. The portal consists of two main components:
- Catalog, orders, order status, eligibility: Salesforce (portal pricebook + orders).
- Support: Salesforce cases with Origin = "Portal Website."
- User-to-account links: Postgres `id_mappings` table, also reflected on Salesforce Account (portal status + WHMCS ID).
- SIM management (usage, top-ups, plan changes, voice features): Freebit API.
- Call/SMS history: SFTP (CSV files from fs.mvno.net, 2 months behind current).
## Core Flows - **Frontend**: Next.js application serving the customer UI
- **BFF (Backend-for-Frontend)**: NestJS API that orchestrates calls to external systems
### 1) Sign-Up and Linking ### Connected Systems
- New sign-up: | System | Role | Integration Method |
- User enters email, password, name, phone, full address, and Customer Number. | ---------------------- | --------------------------------------- | ------------------------------------ |
- We check the Customer Number in Salesforce. If already linked, we stop and ask them to log in. | **WHMCS** | Billing system of record | REST API (API actions) |
- We create a WHMCS client (billing account) with the provided contact + address and store custom fields when present (Customer Number, DOB, Gender, Nationality). | **Salesforce** | CRM and order management | REST API + Change Data Capture (CDC) |
- We create the portal user, store the ID mapping (portal ↔ WHMCS ↔ Salesforce), and update the Salesforce Account with portal status + WHMCS ID. | **Freebit** | SIM/MVNO provisioning | REST API |
- Linking an existing WHMCS user: | **SFTP (fs.mvno.net)** | Call/SMS detail records | SFTP file download |
- Validate WHMCS credentials. | **PostgreSQL** | Portal user accounts and ID mappings | Direct connection |
- Read Customer Number from WHMCS custom field 198 (“Customer Number”) and find the Salesforce Account. | **Redis** | Caching and pub/sub for realtime events | Direct connection |
- Create portal user (no password yet) and ID mapping, mark Salesforce portal flags, and ask the user to set a portal password.
- If something goes wrong: clear messages (invalid credentials, duplicate account, missing Customer Number). If WHMCS creation fails, we do not keep partial records; if the DB write fails after WHMCS creation, we mark the WHMCS client Inactive for cleanup.
What customers see: ### ID Mapping
- Immediate validation of Customer Number; clear prompts if the account already exists. The portal maintains a mapping table (`id_mappings` in PostgreSQL) linking:
- Errors are phrased as actions: “Log in instead,” “Add missing billing info,” “Invalid WHMCS password.”
### 2) Profile and Address - `user_id` (portal UUID) ↔ `whmcs_client_id` (integer) ↔ `sf_account_id` (Salesforce 18-char ID)
- Address/profile edits are written to WHMCS (authoritative for billing). This mapping is validated on every cross-system operation. The mapping is cached in Redis and can be looked up by any of the three IDs.
- WHMCS cache for that user is cleared so new data shows up immediately.
- Salesforce only gets address snapshots on orders (so orders show the address used at checkout).
- Password changes are portal-only; WHMCS credentials are never stored.
### 2.5) Password Reset ---
- Users can request a password reset via email. ## Data Ownership and Flow
- The portal sends a reset link with a time-limited token.
- After resetting, all existing sessions are invalidated and the user must log in again.
- Rate limited to 5 attempts per 15 minutes to prevent abuse.
- The response is always "If an account exists, a reset email has been sent" to avoid leaking account existence.
### 3) Catalog and Eligibility | Data | System of Record | Portal Behavior |
| -------------------------- | ------------------------- | ------------------------------------------------------------------------- |
| User credentials | Portal (PostgreSQL) | Passwords hashed with Argon2; no WHMCS/SF credentials stored |
| Client profile & address | WHMCS | Portal reads/writes to WHMCS; clears cache on update |
| Product catalog & prices | Salesforce (Pricebook) | Portal reads from portal pricebook (configured via `PORTAL_PRICEBOOK_ID`) |
| Orders & order status | Salesforce (Order object) | Portal creates orders; Salesforce CDC triggers fulfillment |
| Invoices & payment methods | WHMCS | Portal reads only; payments via WHMCS SSO |
| Subscriptions/Services | WHMCS (tblhosting) | Portal reads only |
| Support cases | Salesforce (Case object) | Portal creates/reads cases with Origin = "Portal Website" |
| SIM details & usage | Freebit | Portal reads/writes via Freebit API |
- Products and prices come from the Salesforce portal pricebook (`PORTAL_PRICEBOOK_ID`). ---
- Categories: Internet, VPN, SIM/mobile; only portal-marked products are shown.
- SIM family plans: if the user already has an active SIM in WHMCS, we show family/discount SIM plans; otherwise we hide family plans.
- Eligibility: Internet checks account-specific eligibility in Salesforce. Result is cached per account and invalidated on Salesforce changes.
- If something goes wrong: missing payment method blocks checkout; duplicate Internet or ineligible address stops the order with a clear reason; catalog unavailability returns a “try again later.”
### 4) Checkout and Order Creation ## Account Creation and Linking
- Pre-checks: user must have a WHMCS client mapping and at least one WHMCS payment method. Internet orders also check for existing active Internet services in WHMCS (production). ### Salesforce Account Lookup
- Salesforce Order created with:
- AccountId from mapping
- Order type (Internet, SIM, VPN)
- Activation preferences (type, schedule, SIM/MNP details)
- Address snapshot (from profile or updated during checkout)
- Status = “Pending Review”
- Order items are built from Salesforce pricebook entries for the selected SKUs.
- No card data is stored; we only verify payment method existence in WHMCS.
### 5) Fulfillment to WHMCS (and Freebit for SIM) The portal finds a Salesforce Account using the Customer Number:
- Trigger: Salesforce Change Data Capture (CDC) sees status changes (e.g., Approved/Reactivate) and queues a provisioning job. ```sql
- Steps: SELECT Id, Name, WH_Account__c
1. Set Salesforce activation status to “Activating.” FROM Account
2. Map Salesforce order items to WHMCS products and call WHMCS AddOrder (creates billing order, invoices, subscriptions). WHERE SF_Account_No__c = '{customerNumber}'
3. For SIM orders, call Freebit to activate SIMs when required. ```
4. Update Salesforce with WHMCS Order ID, activation status, and any error codes/messages.
5. Publish live events so the UI can stream status updates.
- If payment method is missing, provisioning pauses and writes `PAYMENT_METHOD_MISSING` to Salesforce so the UI can prompt the customer.
- If WHMCS/Salesforce is down, we fail fast with “try again later” and avoid partial writes.
### 6) Billing, Invoices, and Payments | Field | Purpose |
| ------------------ | ----------------------------------------------------------- |
| `SF_Account_No__c` | Customer Number field used for lookup |
| `Id` | The Salesforce Account ID (18-char) stored in `id_mappings` |
| `WH_Account__c` | If populated, indicates account is already linked to WHMCS |
- Data source: WHMCS (clients, invoices, payment methods, gateways, subscriptions). ### New Customer Sign-Up
- Invoice list and detail come from WHMCS; SSO links are generated so customers pay directly in WHMCS without re-entering credentials.
- Profile/address edits go to WHMCS and clear related caches so billing data reflects changes immediately.
- If billing is unavailable, we return a friendly message and avoid partial data.
- If an invoice is not found or belongs to another account, we respond "invoice not found" to avoid leakage.
**SSO Links**: **Validation Steps:**
- The portal generates time-limited SSO tokens (~60 seconds) that redirect users to WHMCS for payment. 1. Check if email already exists in portal `users` table
- Payment methods and gateways can be pre-selected via URL parameters. - If exists with mapping → "You already have an account. Please sign in."
- SSO links can also be used to access the WHMCS client area for account management (e.g., adding payment methods). - If exists without mapping → "Please sign in to continue setup."
- The portal normalizes SSO redirect URLs to use the configured WHMCS base URL. 2. Query Salesforce Account by Customer Number (`SF_Account_No__c`)
- If not found → "Salesforce account not found for Customer Number"
3. Check if `WH_Account__c` field is already populated on Salesforce Account
- If populated → "You already have an account. Please use the login page."
4. Check if email exists in WHMCS via `GetClientsDetails`
- If found with portal mapping → "You already have an account. Please sign in."
- If found without mapping → "We found an existing billing account. Please link your account instead."
### 7) Subscriptions/Services **Creation Steps (if validation passes):**
- Data source: WHMCS. Statuses (Active, Pending, Suspended, etc.) come directly from WHMCS. 1. Create WHMCS client via `AddClient` API action with:
- Used for dashboard stats (active counts, etc.). - Contact info: `firstname`, `lastname`, `email`, `phonenumber`
- If mapping is missing, we return a clear error; if WHMCS is down, we ask to retry later and do not cache failures. - Address fields: `address1`, `address2`, `city`, `state`, `postcode`, `country`
- Custom field 198 (configurable): Customer Number
- Optional custom fields: DOB, Gender, Nationality (field IDs configurable)
- Password (synced to WHMCS for SSO compatibility)
2. Create portal user record in PostgreSQL (password hashed with Argon2)
3. Create ID mapping in same transaction: `user_id``whmcs_client_id``sf_account_id`
4. Update Salesforce Account with portal fields (see below)
### 8) SIM Management **Salesforce Account Fields Updated:**
SIM subscriptions have additional management capabilities via Freebit integration: | Field | Value | Notes |
| ------------------------------- | --------------- | ------------------------------------------------------ |
| `Portal_Status__c` | "Active" | Configurable via `ACCOUNT_PORTAL_STATUS_FIELD` |
| `Portal_Registration_Source__c` | "Portal" | Configurable via `ACCOUNT_PORTAL_STATUS_SOURCE_FIELD` |
| `Portal_Last_SignIn__c` | ISO timestamp | Configurable via `ACCOUNT_PORTAL_LAST_SIGNED_IN_FIELD` |
| `WH_Account__c` | WHMCS Client ID | Configurable via `ACCOUNT_WHMCS_FIELD` |
- **Data Usage**: View remaining quota (MB/GB), used data (today/monthly), and usage history. **Rollback Behavior:**
- **Top-Up Data**: Add data quota with optional campaign codes and expiry dates; can be scheduled.
- **Change Plan**: Switch to a different SIM plan. Changes are scheduled for the 1st of the following month (not immediate).
- **Reissue SIM/eSIM**: Request a replacement SIM card or reissue an eSIM profile.
- **Cancel SIM**: Cancel the SIM subscription. Once requested, plan changes are blocked.
- **Call History**: Access call/SMS history via SFTP-provided CSV files (talk-detail and sms-detail).
- **Voice Features**: Toggle voicemail, call waiting, and international roaming (takes effect immediately).
- **Network Type**: Switch between 4G and 5G (takes effect immediately).
**Important timing constraints**: - If WHMCS client creation fails → no portal record created
- If portal DB transaction fails after WHMCS creation → WHMCS client marked `Inactive` for manual cleanup
- Voice features, network type changes, and plan changes must be **at least 30 minutes apart**. ### Linking Existing WHMCS Account
- Voice/network changes before the 25th of the month for billing cycle alignment.
- Plan changes and cancellations cannot coexist; a plan change will cancel a pending cancellation.
- Device restart may be required after changes are applied.
### 9) Support Cases **Flow:**
- Data source: Salesforce. Origin is "Portal Website." 1. Customer submits WHMCS email and password
- The portal shows only cases for the customer's mapped Salesforce Account. 2. Portal validates credentials via WHMCS `ValidateLogin` API action
- Create: requires subject/description; optional category/type and priority; sets Status = New. 3. Check if WHMCS client is already mapped → "This billing account is already linked. Please sign in."
- Reads are live (no cache) so status/priority/comments are up to date. 4. Portal reads Customer Number from WHMCS custom field 198 via `GetClientsDetails`
- If Salesforce is unavailable, we show "support system unavailable" and avoid leaking details. 5. Portal queries Salesforce Account by Customer Number (`SF_Account_No__c`)
6. Portal creates local user (without password needs to be set)
7. Portal inserts ID mapping
8. Portal updates Salesforce Account with portal flags (`Portal_Status__c` = "Active", etc.)
9. Customer is prompted to set a portal password
### 10) Dashboard ---
The dashboard provides a summary view for customers: ## Profile and Address Management
- **Stats cards**: Recent orders count, pending invoices, active services, open support cases. ### Data Source
- **Upcoming invoice**: Shows the next invoice due with amount and a "Pay Now" button that opens WHMCS payment via SSO.
- **Recent activity feed**: Filterable list of recent events (invoices created/paid, services activated, cases created/closed).
- **Quick actions**: Links to view invoices, manage services, and open support tickets.
- Dashboard data is aggregated from multiple sources (WHMCS for billing/subscriptions, Salesforce for orders/cases).
## What Can Go Wrong (and What We Do) - All profile/address data is read from WHMCS via `GetClientsDetails` API action
- Portal caches client profile for 30 minutes
- Sign-up/linking: missing Customer Number, already-linked account, or WHMCS email collision → block and show the action (log in, link instead, contact support). WHMCS creation failure leaves no portal record; DB failure after WHMCS creation marks the WHMCS client Inactive for cleanup. ### Updates
- Catalog/eligibility: pricebook issues or eligibility lookup failures → "catalog unavailable/try later"; missing payment method → block checkout; duplicate Internet → block with a clear explanation.
- Checkout/order create: if mapping or payment method is missing, we stop; if Salesforce is down, we do not create partial orders.
- Fulfillment: payment method missing → pause with `PAYMENT_METHOD_MISSING` in Salesforce; WHMCS/Freebit errors → mark failed with messages in Salesforce; we avoid partial WHMCS orders on failure.
- Billing: WHMCS down → "billing system unavailable"; invoice not found → "invoice not found" (no other data leaked).
- Subscriptions: missing mapping → explicit error; WHMCS down → ask to retry later, do not cache the failure.
- Support: Salesforce down → "support system unavailable"; wrong account → "case not found" to avoid leaks.
- SIM management: operation timing violations → block with "must wait 30 minutes"; pending cancellation → block plan changes; Freebit unavailable → "try again later"; non-SIM subscription → "SIM management not available."
- Cache failures: fall back to live reads to avoid empty screens; caches are cleared on writes and webhooks to keep data fresh.
- Password reset: always responds with generic message ("If an account exists...") to avoid account enumeration.
## Caching Cheat Sheet (Redis) - Profile updates are written to WHMCS via `UpdateClient` API action
- Cache is invalidated immediately after successful update
- Salesforce only receives address snapshots at order creation time (not live synced)
- Catalog: event-driven (Salesforce CDC), no TTL; volatile bits use 60s TTL; eligibility per account cached without TTL and invalidated on change. ### Available Fields
- Orders: event-driven (Salesforce CDC), no TTL; invalidated on Salesforce order/order-item changes and on create/provision.
- Invoices: list cached 90s; invoice detail 5m; invalidated by WHMCS webhooks and write ops.
- Subscriptions/services: list 5m; single subscription 10m; invalidated on WHMCS cache busts (webhooks, profile updates).
- Payment methods: 15m; payment gateways: 1h.
- WHMCS client profile: 30m; cleared after profile/address changes.
- Signup account lookup: 30s to keep the form responsive.
- Support cases: live (no cache).
- If cache access fails, we fall back to live reads to avoid empty screens.
## IDs and Mapping | Field | WHMCS Field | Notes |
| ---------------- | ------------- | -------------------- |
| First Name | `firstname` | |
| Last Name | `lastname` | |
| Email | `email` | |
| Phone | `phonenumber` | Format: +CC.NNNNNNNN |
| Address Line 1 | `address1` | |
| Address Line 2 | `address2` | Optional |
| City | `city` | |
| State/Prefecture | `state` | |
| Postcode | `postcode` | |
| Country | `country` | 2-letter ISO code |
- Each portal user is mapped to exactly one WHMCS client and one Salesforce Account via `id_mappings`. ---
- Mappings are validated on every operation that needs cross-system data (orders, billing, cases).
- Duplicate protection: we block sign-up/link if the mapping already exists.
## Events and Queues ## Password Management
- Salesforce Change Data Capture (CDC): drives catalog invalidation and order cache invalidation. ### Portal Password
- Salesforce Platform Events / CDC: drive order fulfillment and status updates.
- Provisioning queue (BullMQ): ensures WHMCS/Freebit steps are retried safely and not run in parallel when status doesn't allow it.
- Real-time order events: published so the UI can live-update order status via SSE.
### Realtime Events (SSE) - Stored in PostgreSQL hashed with Argon2
- Completely separate from WHMCS credentials after initial sync
- WHMCS password is set during signup for SSO compatibility but not synced thereafter
The portal provides a single SSE endpoint (`/api/events`) for live updates: ### Password Reset Flow
- **Account-scoped stream**: Receives order status updates, fulfillment events, and other account-specific changes. 1. Customer requests reset via email
- **Global catalog stream**: Receives catalog/pricebook invalidation events. 2. Portal generates time-limited token (stored in DB)
- **Heartbeats**: Sent every 30 seconds to keep the connection alive. 3. Email sent with reset link
- **Connection limiting**: Each user can only have a limited number of concurrent SSE connections to prevent resource abuse. 4. Customer submits new password with token
- **Rate limiting**: 30 connection attempts per minute to protect against reconnect storms. 5. All existing sessions are invalidated (token blacklist)
- Backed by Redis pub/sub for multi-instance delivery. 6. Customer must log in again
## How We Handle Errors (Summary) ### Rate Limiting
- Fail safe and clear: stop the action and tell the user what to fix (e.g., missing Customer Number, duplicate account, missing payment method). - Password reset requests: 5 per 15 minutes per IP
- Avoid partial writes: if upstream systems fail, we do not store partial data; caches are not polluted with failures. - Response always: "If an account exists, a reset email has been sent" (prevents enumeration)
- Surface to Salesforce: fulfillment error codes/messages are written back so teams can see why an order paused.
- Security: if something is not found or belongs to another account, we return "not found" to avoid leaking data. ---
## Product Catalog and Eligibility
### Catalog Source
- Products pulled from Salesforce Pricebook configured via `PORTAL_PRICEBOOK_ID`
- Only products marked for portal visibility are shown
- Categories: Internet, SIM/Mobile, VPN
### Caching
- Catalog cached in Redis without TTL
- Invalidated via Salesforce CDC when `PricebookEntry` records change
- Volatile/dynamic data uses 60-second TTL
### SIM Family Plans
- Portal checks WHMCS for existing active SIM subscriptions via `GetClientsProducts`
- Filter: `status = Active` AND product in SIM group
- If active SIM exists → family/discount SIM plans are shown
- If no active SIM → family plans are hidden
### Internet Eligibility
- Portal queries Salesforce for account-specific eligibility
- Eligibility result cached per `sf_account_id` (no TTL, invalidated on Salesforce change)
- Checks for duplicate active Internet services in WHMCS before allowing order
---
## Order Creation
### Pre-Checkout Validation
1. User must have valid ID mapping (`whmcs_client_id` exists)
2. User must have at least one payment method in WHMCS (via `GetPayMethods`)
3. For Internet orders: no existing active Internet service in WHMCS
### Salesforce Order Structure
**Order object fields:**
| Field | Value |
| ------------------------ | --------------------------------- |
| `AccountId` | From ID mapping (`sf_account_id`) |
| `EffectiveDate` | Today's date |
| `Status` | "Pending Review" |
| `Pricebook2Id` | Portal pricebook ID |
| `Type__c` | Internet / SIM / VPN |
| `Activation_Type__c` | Immediate / Scheduled |
| `Activation_Schedule__c` | Date if scheduled |
| Address fields | Snapshot from WHMCS profile |
**OrderItem records:**
- Created via Salesforce Composite API
- `PricebookEntryId` from catalog lookup by SKU
- `Quantity` and `UnitPrice` from pricebook
### No Payment Storage
- Portal verifies payment method exists but does not store or process card data
- Actual payment occurs in WHMCS after fulfillment creates invoice
---
## Order Fulfillment and Provisioning
### Trigger
- Salesforce CDC detects Order status change (e.g., to "Approved" or "Reactivate")
- Event is published and picked up by the portal's provisioning queue (BullMQ)
- Idempotency key prevents duplicate processing
### Provisioning Steps
1. Update Salesforce `Activation_Status__c` = "Activating"
2. Map Salesforce OrderItems to WHMCS products
3. Call WHMCS `AddOrder` API action:
- Creates WHMCS order, invoice, and hosting/subscription records
- `paymentmethod`: "stripe"
- `promocode`: applied if configured
- `noinvoiceemail`: true (portal handles notifications)
4. For SIM orders: call Freebit activation API
5. Update Salesforce Order with:
- `WHMCS_Order_ID__c`
- `Activation_Status__c` = "Active" or error status
- Error codes/messages if failed
6. Invalidate order cache
7. Publish realtime event for UI live update
### Distributed Transaction
The fulfillment uses a distributed transaction pattern with rollback:
- If WHMCS creation fails after Salesforce status update → rollback Salesforce status
- No partial orders are left in WHMCS
### Error Handling
| Scenario | Behavior |
| ---------------------- | ------------------------------------------------- |
| Missing payment method | Pause with `PAYMENT_METHOD_MISSING` in Salesforce |
| WHMCS API failure | Mark failed; rollback Salesforce status |
| Freebit failure | Mark failed; error written to Salesforce |
| Transient failure | Retry via BullMQ queue with backoff |
---
## Billing and Payments
### Invoice Data
- Fetched from WHMCS via `GetInvoices` and `GetInvoice` API actions
- List cached 90 seconds; individual invoice cached 5 minutes
- Invalidated by WHMCS webhooks and portal write operations
### Payment Methods
- Fetched from WHMCS via `GetPayMethods` API action
- Cached 15 minutes per user
- Portal transforms WHMCS response to normalized format
- First payment method marked as default
### Payment Gateways
- Fetched from WHMCS via `GetPaymentMethods` API action
- Cached 1 hour (rarely changes)
### SSO Links for Payment
- Portal generates WHMCS SSO token via `CreateSsoToken` API action
- Destination: `index.php?rp=/invoice/{id}/pay`
- Token valid ~60 seconds
- Payment method or gateway can be pre-selected via URL params
- Portal normalizes redirect URL to configured WHMCS base URL
---
## Subscriptions and Services
### Data Source
- WHMCS `GetClientsProducts` API action
- Returns hosting/subscription records from `tblhosting`
### Cached Fields
| Field | WHMCS Source |
| ----------------- | ------------------------------------------------------ |
| ID | `id` |
| Product Name | `name` / `groupname` |
| Status | `status` (Active, Pending, Suspended, Cancelled, etc.) |
| Registration Date | `regdate` |
| Next Due Date | `nextduedate` |
| Amount | `amount` |
| Billing Cycle | `billingcycle` |
### Caching
- List cached 5 minutes
- Individual subscription cached 10 minutes
- Invalidated on WHMCS webhooks or profile updates
---
## SIM Management
For subscriptions identified as SIM products, additional management is available via Freebit API.
### Identifying SIM Subscriptions
- Portal checks if product name contains "SIM" (case-insensitive)
- SIM management UI only shown for matching subscriptions
### Data Retrieval
**Account Details (PA05-01 getAcnt / master/getAcnt):**
| Portal Field | Freebit Field |
| --------------------- | --------------------------------------------- |
| MSISDN | `msisdn` |
| ICCID | `iccid` |
| IMSI | `imsi` |
| EID | `eid` |
| Plan Code | `planCode` |
| Status | `status` (active/suspended/cancelled/pending) |
| SIM Type | `simType` (physical/esim/standard/nano/micro) |
| Remaining Quota (KB) | `remainingQuotaKb` |
| Voice Mail | `voiceMailEnabled` |
| Call Waiting | `callWaitingEnabled` |
| International Roaming | `internationalRoamingEnabled` |
| Network Type | `networkType` (4G/5G) |
**Usage (getTrafficInfo):**
- Today's usage (MB)
- Monthly usage (MB)
- Recent daily breakdown
### Available Operations
| Operation | Freebit API | Effect Timing |
| --------------------- | --------------------------------- | ------------------------------------------------------- |
| Top-Up Data | `addSpec` / `eachQuota` | Immediate or scheduled |
| Change Plan | PA05-21 `changePlan` | 1st of following month (requires `runTime` in YYYYMMDD) |
| Update Voice Features | PA05-06 `talkoption/changeOrder` | Immediate |
| Update Network Type | PA05-38 `contractline/change` | Immediate |
| Cancel SIM Plan | PA05-04 `releasePlan` | Scheduled |
| Cancel SIM Account | PA02-04 `cnclAcnt` | Scheduled (requires `runDate`) |
| Reissue eSIM | `reissueEsim` / PA05-41 `addAcct` | As scheduled |
### Voice Feature Values
| Feature | Enable Value | Disable Value |
| -------------------- | ------------ | ------------- |
| Voice Mail | "10" | "20" |
| Call Waiting | "10" | "20" |
| World Wing (Roaming) | "10" | "20" |
When enabling World Wing, `worldWingCreditLimit` is set to "50000" (minimum permitted).
### Operation Timing Constraints
Critical rule: **30-minute minimum gap** between these operations:
- Voice feature changes (PA05-06)
- Network type changes (PA05-38)
- Plan changes (PA05-21)
Additional constraints:
- PA05-21 (plan change) and PA02-04 (cancellation) cannot coexist
- After PA02-04 cancellation is sent, any PA05-21 call will cancel the cancellation
- Voice/network changes should be made before 25th of month for billing cycle alignment
The portal enforces these constraints with in-memory operation timestamps per Freebit account. Stale entries are cleaned up periodically (entries older than 35 minutes, except cancellations which persist).
### Call/SMS History
- Retrieved via SFTP from `fs.mvno.net`
- Files: `PASI_talk-detail-YYYYMM.csv`, `PASI_sms-detail-YYYYMM.csv`
- Available 2 months behind current date (e.g., November can access September)
- Connection uses SSH key fingerprint verification in production
---
## Support Cases
### Salesforce Case Integration
**Create Case:**
| Field | Value |
| ------------- | --------------------------------- |
| `AccountId` | From ID mapping (`sf_account_id`) |
| `Origin` | "Portal Website" |
| `Subject` | Customer input (required) |
| `Description` | Customer input (required) |
| `Type` | Customer selection (optional) |
| `Priority` | Customer selection (optional) |
| `Status` | "New" |
**Read Cases:**
- Portal queries Cases where `AccountId` matches mapped account
- No caching always live read from Salesforce
### Security
- Cases are strictly filtered to the customer's linked Account
- If case not found or belongs to different account → "case not found" response
---
## Dashboard and Summary Data
The dashboard aggregates data from multiple sources:
| Metric | Source | Query |
| ---------------- | ---------- | --------------------------------------- |
| Recent Orders | Salesforce | Orders for account, last 30 days |
| Pending Invoices | WHMCS | Invoices with status Unpaid/Overdue |
| Active Services | WHMCS | Subscriptions with status Active |
| Open Cases | Salesforce | Cases for account with Status ≠ Closed |
| Next Invoice | WHMCS | First unpaid invoice by due date |
| Activity Feed | Aggregated | Recent invoices, orders, cases combined |
---
## Realtime Events
### SSE Endpoint
- Single endpoint: `GET /api/events`
- Server-Sent Events (SSE) connection
- Requires authentication
- Backed by Redis pub/sub for multi-instance delivery
### Event Streams
| Channel | Events |
| ---------------------------- | ---------------------------------------- |
| `account:sf:{sf_account_id}` | Order status updates, fulfillment events |
| `global:catalog` | Catalog/pricebook invalidation |
### Connection Management
- Heartbeat every 30 seconds (`account.stream.heartbeat`)
- Ready event on connection (`account.stream.ready`)
- Per-user connection limit enforced
- Rate limit: 30 connection attempts per minute
- Connection limiter prevents resource exhaustion
---
## Caching Strategy
### Redis Key Scoping
All cache keys include user identifier to prevent data mix-ups between customers.
### TTL Summary
| Data | TTL | Invalidation Trigger |
| --------------------- | ------------------- | --------------------------------- |
| Catalog | None (event-driven) | Salesforce CDC on PricebookEntry |
| Eligibility | None | Salesforce CDC on Account changes |
| Orders | None (event-driven) | Salesforce CDC on Order/OrderItem |
| ID Mappings | None | Create/update/delete operations |
| Invoices (list) | 90 seconds | WHMCS webhook, write operations |
| Invoice (detail) | 5 minutes | WHMCS webhook, write operations |
| Subscriptions (list) | 5 minutes | WHMCS webhook, profile update |
| Subscription (single) | 10 minutes | WHMCS webhook, profile update |
| Payment Methods | 15 minutes | Add/remove operations |
| Payment Gateways | 1 hour | Rarely changes |
| Client Profile | 30 minutes | Profile/address update |
| Signup Account Lookup | 30 seconds | N/A |
| Support Cases | None (live) | N/A |
### Fallback Behavior
- If cache read fails → fall back to live API call
- Failures are not cached (prevents stale error states)
---
## Error Handling
### General Principles
- Fail safe with clear messages
- No partial writes operations are atomic where possible
- Errors written back to Salesforce for visibility (fulfillment)
- Generic error messages to customers to avoid information leakage
### Error Responses by Area
| Area | Error Scenario | Response |
| ----------- | ---------------------------- | ------------------------------------------------------------------------- |
| Sign-up | Email exists with mapping | "You already have an account. Please sign in." |
| Sign-up | Email exists without mapping | "Please sign in to continue setup." |
| Sign-up | Customer Number not found | "Salesforce account not found for Customer Number" |
| Sign-up | `WH_Account__c` already set | "You already have an account. Please use the login page." |
| Sign-up | Email exists in WHMCS | "We found an existing billing account. Please link your account instead." |
| Sign-up | WHMCS client creation fails | "Failed to create billing account" (no portal record created) |
| Sign-up | DB transaction fails | WHMCS client marked Inactive for cleanup |
| Link | WHMCS client already mapped | "This billing account is already linked. Please sign in." |
| Checkout | No payment method | Block with "Add payment method" prompt |
| Checkout | Duplicate Internet service | Block with explanation |
| Fulfillment | Payment method missing | Pause, write `PAYMENT_METHOD_MISSING` to Salesforce |
| Billing | Invoice not found | "Invoice not found" (no data leakage) |
| Billing | WHMCS unavailable | "Billing system unavailable, try later" |
| SIM | 30-minute rule violation | "Please wait 30 minutes between changes" |
| SIM | Pending cancellation | "Plan changes not allowed after cancellation" |
| SIM | Not a SIM subscription | "This subscription is not a SIM service" |
| Support | Case not found/wrong account | "Case not found" |
---
## Rate Limiting and Security ## Rate Limiting and Security
- All endpoints are rate-limited to prevent abuse. Limits are tracked per IP + User-Agent combination. ### API Rate Limits
- Authentication endpoints have stricter limits: 5 login attempts per 15 minutes, 5 password reset requests per 15 minutes.
- SSE connections are limited per user to prevent resource exhaustion.
- WHMCS and Salesforce requests are queued with concurrency limits to respect upstream API limits.
- Failed authentication attempts are throttled progressively.
- CAPTCHA can be enabled for authentication endpoints after repeated failures.
## What to Expect Day-to-Day | Endpoint Category | Limit | Window |
| ----------------- | ------------ | ---------- |
| General API | 100 requests | 60 seconds |
| Login | 3 attempts | 15 minutes |
| Signup | 5 attempts | 15 minutes |
| Password Reset | 5 attempts | 15 minutes |
| Token Refresh | 10 attempts | 5 minutes |
| SSE Connect | 30 attempts | 60 seconds |
| Order Creation | 5 attempts | 60 seconds |
| Signup Validation | 20 attempts | 10 minutes |
- Data freshness: caches are short or event-driven; updates in WHMCS/Salesforce reflect quickly because we invalidate on change. ### Rate Limit Key
- Address authority: WHMCS is the billing source of truth; Salesforce shows order-time snapshots.
- Payment handling: portal never stores payment details; it only checks that WHMCS has a method on file. - Composed of: IP address + User-Agent hash
- SIM experience: existing SIM customers see family/discount SIM plans; new SIM customers see standard plans. - Prevents bypass via User-Agent rotation alone
- SIM management: changes to voice/network take effect immediately (~30 minutes); plan changes take effect on the 1st of the following month. - Uses Redis-backed `rate-limiter-flexible`
- Call history: CSV files are available 2 months behind the current month (e.g., in November you can access September's records).
### Upstream Throttling
- WHMCS requests queued with concurrency limit
- Salesforce requests queued with concurrency limit
- Respects Salesforce daily API limits
- Graceful degradation when limits approached
### Security Features
- CAPTCHA integration available (Turnstile/hCaptcha)
- Configurable via `AUTH_CAPTCHA_PROVIDER`, `AUTH_CAPTCHA_SECRET`
- Can be enabled after threshold of failed auth attempts
- Password reset responses are generic (prevents account enumeration)
- Cross-account data access returns "not found" (prevents data leakage)
- Token blacklist for invalidated sessions
- CSRF protection on state-changing operations
---
## Environment Configuration
Key environment variables for system integration:
| Variable | Purpose | Default |
| -------------------------------- | -------------------------------- | -------------------- |
| `WHMCS_API_URL` | WHMCS API endpoint | - |
| `WHMCS_API_IDENTIFIER` | WHMCS API credentials | - |
| `WHMCS_API_SECRET` | WHMCS API credentials | - |
| `WHMCS_CUSTOMER_NUMBER_FIELD_ID` | Custom field for Customer Number | "198" |
| `SALESFORCE_LOGIN_URL` | Salesforce auth endpoint | - |
| `PORTAL_PRICEBOOK_ID` | Salesforce Pricebook for catalog | - |
| `ACCOUNT_PORTAL_STATUS_FIELD` | SF Account field for status | "Portal_Status\_\_c" |
| `ACCOUNT_WHMCS_FIELD` | SF Account field for WHMCS ID | "WH_Account\_\_c" |
| `FREEBIT_API_URL` | Freebit API endpoint | - |
| `SFTP_HOST` | MVNO SFTP server | "fs.mvno.net" |
---
_Last updated: December 2025_

Binary file not shown.

View File

@ -70,6 +70,13 @@ WHMCS_BASE_URL=https://accounts.asolutions.co.jp
WHMCS_API_IDENTIFIER=CHANGE_ME WHMCS_API_IDENTIFIER=CHANGE_ME
WHMCS_API_SECRET=CHANGE_ME WHMCS_API_SECRET=CHANGE_ME
# WHMCS Client custom fields (IDs from WHMCS Admin → Custom Fields)
# Customer Number is typically 198; Gender and DOB are your instance-specific IDs.
WHMCS_CUSTOMER_NUMBER_FIELD_ID=198
WHMCS_GENDER_FIELD_ID=200
WHMCS_DOB_FIELD_ID=201
# WHMCS_NATIONALITY_FIELD_ID=
# Queue settings (defaults shown) # Queue settings (defaults shown)
# WHMCS_QUEUE_CONCURRENCY=15 # WHMCS_QUEUE_CONCURRENCY=15
# WHMCS_QUEUE_INTERVAL_CAP=300 # WHMCS_QUEUE_INTERVAL_CAP=300

View File

@ -20,6 +20,10 @@ import { addressSchema, userSchema } from "../customer/schema.js";
// ============================================================================ // ============================================================================
const genderEnum = z.enum(["male", "female", "other"]); const genderEnum = z.enum(["male", "female", "other"]);
const isoDateOnlySchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Enter a valid date (YYYY-MM-DD)")
.refine(value => !Number.isNaN(Date.parse(value)), "Enter a valid date (YYYY-MM-DD)");
export const loginRequestSchema = z.object({ export const loginRequestSchema = z.object({
email: emailSchema, email: emailSchema,
@ -40,7 +44,7 @@ export const signupInputSchema = z.object({
sfNumber: z.string().min(6, "Customer number must be at least 6 characters"), sfNumber: z.string().min(6, "Customer number must be at least 6 characters"),
address: addressSchema.optional(), address: addressSchema.optional(),
nationality: z.string().optional(), nationality: z.string().optional(),
dateOfBirth: z.string().optional(), dateOfBirth: isoDateOnlySchema.optional(),
gender: genderEnum.optional(), gender: genderEnum.optional(),
acceptTerms: z.boolean(), acceptTerms: z.boolean(),
marketingConsent: z.boolean().optional(), marketingConsent: z.boolean().optional(),
@ -91,6 +95,7 @@ export const validateSignupRequestSchema = z.object({
*/ */
export const updateCustomerProfileRequestSchema = z.object({ export const updateCustomerProfileRequestSchema = z.object({
// Basic profile // Basic profile
email: emailSchema.optional(),
firstname: nameSchema.optional(), firstname: nameSchema.optional(),
lastname: nameSchema.optional(), lastname: nameSchema.optional(),
companyname: z.string().max(100).optional(), companyname: z.string().max(100).optional(),

View File

@ -7,7 +7,11 @@
export * from "./mapper.js"; export * from "./mapper.js";
export * from "./raw.types.js"; export * from "./raw.types.js";
export { getCustomFieldValue, getCustomFieldsMap } from "../../../providers/whmcs/utils.js"; export {
getCustomFieldValue,
getCustomFieldsMap,
serializeWhmcsKeyValueMap,
} from "../../../providers/whmcs/utils.js";
// Re-export domain types for provider namespace convenience // Re-export domain types for provider namespace convenience
export type { WhmcsClient, EmailPreferences, SubUser, Stats } from "../../schema.js"; export type { WhmcsClient, EmailPreferences, SubUser, Stats } from "../../schema.js";

View File

@ -30,7 +30,11 @@ export interface WhmcsAddClientParams {
companyname?: string; companyname?: string;
currency?: string; currency?: string;
groupid?: number; groupid?: number;
customfields?: Record<string, string>; /**
* WHMCS API expects this as a base64 encoded serialized array.
* @see https://developers.whmcs.com/api-reference/addclient/
*/
customfields?: string;
language?: string; language?: string;
clientip?: string; clientip?: string;
notes?: string; notes?: string;

View File

@ -13,7 +13,10 @@
import { z } from "zod"; import { z } from "zod";
import { countryCodeSchema } from "../common/schema.js"; import { countryCodeSchema } from "../common/schema.js";
import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema } from "./providers/whmcs/raw.types.js"; import {
whmcsClientSchema as whmcsRawClientSchema,
whmcsCustomFieldSchema,
} from "./providers/whmcs/raw.types.js";
// ============================================================================ // ============================================================================
// Helper Schemas // Helper Schemas
@ -33,7 +36,9 @@ const normalizeBoolean = (value: unknown): boolean | null | undefined => {
if (typeof value === "number") return value === 1; if (typeof value === "number") return value === 1;
if (typeof value === "string") { if (typeof value === "string") {
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; return (
normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"
);
} }
return null; return null;
}; };
@ -58,10 +63,18 @@ export const addressSchema = z.object({
}); });
export const addressFormSchema = z.object({ export const addressFormSchema = z.object({
address1: z.string().min(1, "Address line 1 is required").max(200, "Address line 1 is too long").trim(), address1: z
.string()
.min(1, "Address line 1 is required")
.max(200, "Address line 1 is too long")
.trim(),
address2: z.string().max(200, "Address line 2 is too long").trim().optional(), address2: z.string().max(200, "Address line 2 is too long").trim().optional(),
city: z.string().min(1, "City is required").max(100, "City name is too long").trim(), city: z.string().min(1, "City is required").max(100, "City name is too long").trim(),
state: z.string().min(1, "State/Prefecture is required").max(100, "State/Prefecture name is too long").trim(), state: z
.string()
.min(1, "State/Prefecture is required")
.max(100, "State/Prefecture name is too long")
.trim(),
postcode: z.string().min(1, "Postcode is required").max(20, "Postcode is too long").trim(), postcode: z.string().min(1, "Postcode is required").max(20, "Postcode is too long").trim(),
country: z.string().min(1, "Country is required").max(100, "Country name is too long").trim(), country: z.string().min(1, "Country is required").max(100, "Country name is too long").trim(),
countryCode: countryCodeSchema.optional(), countryCode: countryCodeSchema.optional(),
@ -78,8 +91,7 @@ export const addressFormSchema = z.object({
* Contains basic editable user profile fields (WHMCS field names) * Contains basic editable user profile fields (WHMCS field names)
*/ */
export const profileEditFormSchema = z.object({ export const profileEditFormSchema = z.object({
firstname: z.string().min(1, "First name is required").max(100).trim(), email: z.string().email("Enter a valid email").trim(),
lastname: z.string().min(1, "Last name is required").max(100).trim(),
phonenumber: z.string().optional(), phonenumber: z.string().optional(),
}); });
@ -88,7 +100,7 @@ export const profileEditFormSchema = z.object({
* Used for displaying profile information * Used for displaying profile information
*/ */
export const profileDisplayDataSchema = profileEditFormSchema.extend({ export const profileDisplayDataSchema = profileEditFormSchema.extend({
email: z.string().email(), // no extra fields (kept for backwards compatibility)
}); });
// ============================================================================ // ============================================================================
@ -126,46 +138,47 @@ export const userAuthSchema = z.object({
* Email preferences from WHMCS * Email preferences from WHMCS
* Internal to Providers.Whmcs namespace * Internal to Providers.Whmcs namespace
*/ */
const emailPreferencesSchema = z.object({ const emailPreferencesSchema = z
general: booleanLike.optional(), .object({
invoice: booleanLike.optional(), general: booleanLike.optional(),
support: booleanLike.optional(), invoice: booleanLike.optional(),
product: booleanLike.optional(), support: booleanLike.optional(),
domain: booleanLike.optional(), product: booleanLike.optional(),
affiliate: booleanLike.optional(), domain: booleanLike.optional(),
}).transform(prefs => ({ affiliate: booleanLike.optional(),
general: normalizeBoolean(prefs.general), })
invoice: normalizeBoolean(prefs.invoice), .transform(prefs => ({
support: normalizeBoolean(prefs.support), general: normalizeBoolean(prefs.general),
product: normalizeBoolean(prefs.product), invoice: normalizeBoolean(prefs.invoice),
domain: normalizeBoolean(prefs.domain), support: normalizeBoolean(prefs.support),
affiliate: normalizeBoolean(prefs.affiliate), product: normalizeBoolean(prefs.product),
})); domain: normalizeBoolean(prefs.domain),
affiliate: normalizeBoolean(prefs.affiliate),
}));
/** /**
* Sub-user from WHMCS * Sub-user from WHMCS
* Internal to Providers.Whmcs namespace * Internal to Providers.Whmcs namespace
*/ */
const subUserSchema = z.object({ const subUserSchema = z
id: numberLike, .object({
name: z.string(), id: numberLike,
email: z.string(), name: z.string(),
is_owner: booleanLike.optional(), email: z.string(),
}).transform(user => ({ is_owner: booleanLike.optional(),
id: Number(user.id), })
name: user.name, .transform(user => ({
email: user.email, id: Number(user.id),
is_owner: normalizeBoolean(user.is_owner), name: user.name,
})); email: user.email,
is_owner: normalizeBoolean(user.is_owner),
}));
/** /**
* Billing stats from WHMCS * Billing stats from WHMCS
* Internal to Providers.Whmcs namespace * Internal to Providers.Whmcs namespace
*/ */
const statsSchema = z.record( const statsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional();
z.string(),
z.union([z.string(), z.number(), z.boolean()])
).optional();
const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema); const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema);
@ -175,9 +188,7 @@ const whmcsCustomFieldsSchema = z
whmcsRawCustomFieldsArraySchema, whmcsRawCustomFieldsArraySchema,
z z
.object({ .object({
customfield: z customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(),
.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
.optional(),
}) })
.passthrough(), .passthrough(),
]) ])
@ -292,6 +303,11 @@ export const userSchema = userAuthSchema.extend({
language: z.string().nullable().optional(), language: z.string().nullable().optional(),
currency_code: z.string().nullable().optional(), // WHMCS uses snake_case for this currency_code: z.string().nullable().optional(), // WHMCS uses snake_case for this
address: addressSchema.optional(), address: addressSchema.optional(),
// Common portal-visible identifiers/custom fields (derived in BFF)
sfNumber: z.string().nullable().optional(),
dateOfBirth: z.string().nullable().optional(),
gender: z.string().nullable().optional(),
}); });
// ============================================================================ // ============================================================================
@ -327,13 +343,11 @@ export function addressFormToRequest(form: AddressFormData): Address {
* No transformation needed - form already uses WHMCS field names * No transformation needed - form already uses WHMCS field names
*/ */
export function profileFormToRequest(form: ProfileEditFormData): { export function profileFormToRequest(form: ProfileEditFormData): {
firstname: string; email: string;
lastname: string;
phonenumber?: string; phonenumber?: string;
} { } {
return { return {
firstname: form.firstname.trim(), email: form.email.trim(),
lastname: form.lastname.trim(),
phonenumber: form.phonenumber?.trim() || undefined, phonenumber: form.phonenumber?.trim() || undefined,
}; };
} }
@ -365,7 +379,11 @@ export function combineToUser(userAuth: UserAuth, whmcsClient: WhmcsClient): Use
lastname: whmcsClient.lastname || null, lastname: whmcsClient.lastname || null,
fullname: whmcsClient.fullname || null, fullname: whmcsClient.fullname || null,
companyname: whmcsClient.companyname || null, companyname: whmcsClient.companyname || null,
phonenumber: whmcsClient.phonenumberformatted || whmcsClient.phonenumber || whmcsClient.telephoneNumber || null, phonenumber:
whmcsClient.phonenumberformatted ||
whmcsClient.phonenumber ||
whmcsClient.telephoneNumber ||
null,
language: whmcsClient.language || null, language: whmcsClient.language || null,
currency_code: whmcsClient.currency_code || null, currency_code: whmcsClient.currency_code || null,
address: whmcsClient.address || undefined, address: whmcsClient.address || undefined,

View File

@ -6,6 +6,7 @@
import type { OrderDetails, OrderItemDetails } from "../../contract.js"; import type { OrderDetails, OrderItemDetails } from "../../contract.js";
import { normalizeBillingCycle } from "../../helpers.js"; import { normalizeBillingCycle } from "../../helpers.js";
import { serializeWhmcsKeyValueMap } from "../../../providers/whmcs/utils.js";
import { import {
type WhmcsOrderItem, type WhmcsOrderItem,
type WhmcsAddOrderParams, type WhmcsAddOrderParams,
@ -24,10 +25,7 @@ export interface OrderItemMappingResult {
/** /**
* Map a single order item to WHMCS format * Map a single order item to WHMCS format
*/ */
export function mapOrderItemToWhmcs( export function mapOrderItemToWhmcs(item: OrderItemDetails, index = 0): WhmcsOrderItem {
item: OrderItemDetails,
index = 0
): WhmcsOrderItem {
if (!item.product?.whmcsProductId) { if (!item.product?.whmcsProductId) {
throw new Error(`Order item ${index} missing WHMCS product ID`); throw new Error(`Order item ${index} missing WHMCS product ID`);
} }
@ -45,9 +43,7 @@ export function mapOrderItemToWhmcs(
* Map order details to WHMCS items format * Map order details to WHMCS items format
* Extracts items from OrderDetails and transforms to WHMCS API format * Extracts items from OrderDetails and transforms to WHMCS API format
*/ */
export function mapOrderToWhmcsItems( export function mapOrderToWhmcsItems(orderDetails: OrderDetails): OrderItemMappingResult {
orderDetails: OrderDetails
): OrderItemMappingResult {
if (!orderDetails.items || orderDetails.items.length === 0) { if (!orderDetails.items || orderDetails.items.length === 0) {
throw new Error("No order items provided for WHMCS mapping"); throw new Error("No order items provided for WHMCS mapping");
} }
@ -125,10 +121,10 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
quantities.push(item.quantity); quantities.push(item.quantity);
// Handle config options - WHMCS expects base64 encoded serialized arrays // Handle config options - WHMCS expects base64 encoded serialized arrays
configOptions.push(serializeForWhmcs(item.configOptions)); configOptions.push(serializeWhmcsKeyValueMap(item.configOptions));
// Handle custom fields - WHMCS expects base64 encoded serialized arrays // Handle custom fields - WHMCS expects base64 encoded serialized arrays
customFields.push(serializeForWhmcs(item.customFields)); customFields.push(serializeWhmcsKeyValueMap(item.customFields));
}); });
const payload: WhmcsAddOrderPayload = { const payload: WhmcsAddOrderPayload = {
@ -165,32 +161,6 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
return payload; return payload;
} }
/**
* Serialize object for WHMCS API
* WHMCS expects base64-encoded serialized data
*/
function serializeForWhmcs(data?: Record<string, string>): string {
if (!data || Object.keys(data).length === 0) {
return "";
}
const entries = Object.entries(data).map(([key, value]) => {
const safeKey = key ?? "";
const safeValue = value ?? "";
return (
`s:${Buffer.byteLength(safeKey, "utf8")}:"${escapePhpString(safeKey)}";` +
`s:${Buffer.byteLength(safeValue, "utf8")}:"${escapePhpString(safeValue)}";`
);
});
const serialized = `a:${entries.length}:{${entries.join("")}}`;
return Buffer.from(serialized).toString("base64");
}
function escapePhpString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/** /**
* Create order notes with Salesforce tracking information * Create order notes with Salesforce tracking information
*/ */

View File

@ -142,3 +142,41 @@ export function getCustomFieldValue(
return undefined; return undefined;
} }
/**
* Serialize a key/value map into the format WHMCS expects for request parameters like `customfields`.
*
* Official docs:
* - AddClient: customfields = "Base64 encoded serialized array of custom field values."
* @see https://developers.whmcs.com/api-reference/addclient/
*
* Notes:
* - WHMCS uses PHP serialization. For our usage, keys/values are strings.
* - Output is base64 of the serialized string.
*/
export function serializeWhmcsKeyValueMap(data?: Record<string, string>): string {
if (!data) return "";
const entries = Object.entries(data).filter(([k]) => String(k).trim().length > 0);
if (entries.length === 0) return "";
const serializedEntries = entries.map(([key, value]) => {
const safeKey = key ?? "";
const safeValue = value ?? "";
return (
`s:${byteLengthUtf8(safeKey)}:"${escapePhpString(safeKey)}";` +
`s:${byteLengthUtf8(safeValue)}:"${escapePhpString(safeValue)}";`
);
});
const serialized = `a:${serializedEntries.length}:{${serializedEntries.join("")}}`;
// Node runtime: base64 via Buffer
return Buffer.from(serialized, "utf8").toString("base64");
}
function escapePhpString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function byteLengthUtf8(value: string): number {
return Buffer.byteLength(value, "utf8");
}