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:
parent
0d43e0d0b9
commit
545e62b8a1
@ -62,6 +62,11 @@ export const envSchema = z.object({
|
||||
WHMCS_DEV_BASE_URL: z.string().url().optional(),
|
||||
WHMCS_DEV_API_IDENTIFIER: 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_USERNAME: z.string().optional(),
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
type SignupRequest,
|
||||
type ValidateSignupRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
@ -224,18 +225,17 @@ export class SignupWorkflowService {
|
||||
}
|
||||
|
||||
const customerNumberFieldId = this.configService.get<string>(
|
||||
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
|
||||
"198"
|
||||
"WHMCS_CUSTOMER_NUMBER_FIELD_ID"
|
||||
);
|
||||
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
||||
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
||||
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||
|
||||
const customfields: Record<string, string> = {};
|
||||
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
||||
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
||||
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
||||
const customfieldsMap: Record<string, string> = {};
|
||||
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber;
|
||||
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
|
||||
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
|
||||
|
||||
if (
|
||||
!address?.address1 ||
|
||||
@ -268,7 +268,8 @@ export class SignupWorkflowService {
|
||||
postcode: address.postcode,
|
||||
country: address.country,
|
||||
password2: password,
|
||||
customfields,
|
||||
customfields:
|
||||
CustomerProviders.Whmcs.serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
|
||||
});
|
||||
|
||||
this.logger.log("WHMCS client created successfully", {
|
||||
|
||||
@ -62,4 +62,17 @@ export class UserAuthRepository {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 type { User as PrismaUser } from "@prisma/client";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -30,6 +37,7 @@ export class UserProfileService {
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -112,12 +120,39 @@ export class UserProfileService {
|
||||
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
||||
|
||||
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);
|
||||
if (!mapping) {
|
||||
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");
|
||||
|
||||
@ -381,7 +416,39 @@ export class UserProfileService {
|
||||
try {
|
||||
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
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) {
|
||||
this.logger.error("Failed to fetch client profile from WHMCS", {
|
||||
error: getErrorMessage(error),
|
||||
|
||||
@ -13,9 +13,18 @@ import { useZodForm } from "@/hooks/useZodForm";
|
||||
|
||||
export function useProfileEdit(initial: ProfileEditFormData) {
|
||||
const handleSave = useCallback(async (formData: ProfileEditFormData) => {
|
||||
const previousEmail = useAuthStore.getState().user?.email ?? null;
|
||||
const requestData: UpdateCustomerProfileRequest = profileFormToRequest(formData);
|
||||
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 => ({
|
||||
...state,
|
||||
user: state.user ? { ...state.user, ...updated } : state.user,
|
||||
|
||||
@ -26,8 +26,7 @@ export default function ProfileContainer() {
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
const profile = useProfileEdit({
|
||||
firstname: user?.firstname || "",
|
||||
lastname: user?.lastname || "",
|
||||
email: user?.email || "",
|
||||
phonenumber: user?.phonenumber || "",
|
||||
});
|
||||
|
||||
@ -67,16 +66,14 @@ export default function ProfileContainer() {
|
||||
address.setValue("phoneCountryCode", addr.phoneCountryCode ?? "");
|
||||
}
|
||||
if (prof) {
|
||||
profile.setValue("firstname", prof.firstname || "");
|
||||
profile.setValue("lastname", prof.lastname || "");
|
||||
profile.setValue("email", prof.email || "");
|
||||
profile.setValue("phonenumber", prof.phonenumber || "");
|
||||
useAuthStore.setState(state => ({
|
||||
...state,
|
||||
user: state.user
|
||||
? {
|
||||
...state.user,
|
||||
firstname: prof.firstname || state.user.firstname,
|
||||
lastname: prof.lastname || state.user.lastname,
|
||||
email: prof.email || state.user.email,
|
||||
phonenumber: prof.phonenumber || state.user.phonenumber,
|
||||
}
|
||||
: (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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
|
||||
{editingProfile ? (
|
||||
<input
|
||||
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">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-base text-gray-900 font-medium">
|
||||
{user?.firstname || <span className="text-gray-500 italic">Not provided</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Name cannot be changed from the portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Last Name</label>
|
||||
{editingProfile ? (
|
||||
<input
|
||||
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">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-base text-gray-900 font-medium">
|
||||
{user?.lastname || <span className="text-gray-500 italic">Not provided</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Name cannot be changed from the portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
{editingProfile ? (
|
||||
<input
|
||||
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 cannot be changed from the portal.
|
||||
Email can be updated from the portal.
|
||||
</p>
|
||||
</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">
|
||||
Date of birth is stored in billing profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -253,6 +279,16 @@ export default function ProfileContainer() {
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{editingProfile && (
|
||||
|
||||
@ -9,10 +9,7 @@ import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { ErrorMessage } from "@/components/atoms";
|
||||
import { useSignup } from "../../hooks/use-auth";
|
||||
import {
|
||||
signupInputSchema,
|
||||
buildSignupRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
|
||||
import { addressFormSchema } from "@customer-portal/domain/customer";
|
||||
import { useZodForm } from "@/hooks/useZodForm";
|
||||
import { z } from "zod";
|
||||
@ -32,18 +29,16 @@ import { ReviewStep } from "./steps/ReviewStep";
|
||||
*/
|
||||
const signupFormBaseSchema = signupInputSchema.extend({
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
phoneCountryCode: z
|
||||
.string()
|
||||
.regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
||||
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
||||
address: addressFormSchema,
|
||||
});
|
||||
|
||||
const signupFormSchema = signupFormBaseSchema
|
||||
.refine((data) => data.acceptTerms === true, {
|
||||
.refine(data => data.acceptTerms === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
path: ["acceptTerms"],
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
.refine(data => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
@ -79,10 +74,7 @@ const STEPS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STEP_FIELD_KEYS: Record<
|
||||
(typeof STEPS)[number]["key"],
|
||||
Array<keyof SignupFormData>
|
||||
> = {
|
||||
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
|
||||
account: [
|
||||
"sfNumber",
|
||||
"firstName",
|
||||
@ -90,16 +82,15 @@ const STEP_FIELD_KEYS: Record<
|
||||
"email",
|
||||
"phone",
|
||||
"phoneCountryCode",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
],
|
||||
address: ["address"],
|
||||
password: ["password", "confirmPassword"],
|
||||
review: ["acceptTerms"],
|
||||
};
|
||||
|
||||
const STEP_VALIDATION_SCHEMAS: Record<
|
||||
(typeof STEPS)[number]["key"],
|
||||
z.ZodTypeAny | undefined
|
||||
> = {
|
||||
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
|
||||
account: signupFormBaseSchema.pick({
|
||||
sfNumber: true,
|
||||
firstName: true,
|
||||
@ -107,6 +98,8 @@ const STEP_VALIDATION_SCHEMAS: Record<
|
||||
email: true,
|
||||
phone: true,
|
||||
phoneCountryCode: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
}),
|
||||
address: signupFormBaseSchema.pick({
|
||||
address: true,
|
||||
@ -116,7 +109,7 @@ const STEP_VALIDATION_SCHEMAS: Record<
|
||||
password: true,
|
||||
confirmPassword: true,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
.refine(data => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
}),
|
||||
@ -124,17 +117,13 @@ const STEP_VALIDATION_SCHEMAS: Record<
|
||||
.pick({
|
||||
acceptTerms: true,
|
||||
})
|
||||
.refine((data) => data.acceptTerms === true, {
|
||||
.refine(data => data.acceptTerms === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
path: ["acceptTerms"],
|
||||
}),
|
||||
};
|
||||
|
||||
export function SignupForm({
|
||||
onSuccess,
|
||||
onError,
|
||||
className = "",
|
||||
}: SignupFormProps) {
|
||||
export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
|
||||
const { signup, loading, error, clearError } = useSignup();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
@ -148,6 +137,8 @@ export function SignupForm({
|
||||
phone: "",
|
||||
phoneCountryCode: "+81",
|
||||
company: "",
|
||||
dateOfBirth: undefined,
|
||||
gender: undefined,
|
||||
address: {
|
||||
address1: "",
|
||||
address2: "",
|
||||
@ -162,7 +153,7 @@ export function SignupForm({
|
||||
acceptTerms: false,
|
||||
marketingConsent: false,
|
||||
},
|
||||
onSubmit: async (data) => {
|
||||
onSubmit: async data => {
|
||||
clearError();
|
||||
try {
|
||||
// Combine country code + phone for WHMCS format: +CC.NNNNNNNN
|
||||
@ -174,6 +165,8 @@ export function SignupForm({
|
||||
const request = buildSignupRequest({
|
||||
...data,
|
||||
phone: formattedPhone,
|
||||
dateOfBirth: data.dateOfBirth || undefined,
|
||||
gender: data.gender || undefined,
|
||||
address: {
|
||||
...data.address,
|
||||
country: "JP",
|
||||
@ -208,7 +201,7 @@ export function SignupForm({
|
||||
return;
|
||||
}
|
||||
const fields = STEP_FIELD_KEYS[stepKey] ?? [];
|
||||
fields.forEach((field) => setFormTouchedField(field));
|
||||
fields.forEach(field => setFormTouchedField(field));
|
||||
},
|
||||
[setFormTouchedField]
|
||||
);
|
||||
@ -240,11 +233,11 @@ export function SignupForm({
|
||||
return;
|
||||
}
|
||||
|
||||
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
setStep(s => Math.min(s + 1, STEPS.length - 1));
|
||||
}, [handleSubmit, isLastStep, isStepValid, markStepTouched, step]);
|
||||
|
||||
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
|
||||
@ -254,8 +247,7 @@ export function SignupForm({
|
||||
touched,
|
||||
setValue: (field: string, value: unknown) =>
|
||||
setFormValue(field as keyof SignupFormData, value as never),
|
||||
setTouchedField: (field: string) =>
|
||||
setFormTouchedField(field as keyof SignupFormData),
|
||||
setTouchedField: (field: string) => setFormTouchedField(field as keyof SignupFormData),
|
||||
};
|
||||
|
||||
const stepContent = [
|
||||
@ -284,9 +276,7 @@ export function SignupForm({
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<ErrorMessage className="mt-4 text-center p-3 bg-red-50 rounded-lg">
|
||||
{error}
|
||||
</ErrorMessage>
|
||||
<ErrorMessage className="mt-4 text-center p-3 bg-red-50 rounded-lg">{error}</ErrorMessage>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-3">
|
||||
|
||||
@ -17,6 +17,8 @@ interface AccountStepProps {
|
||||
phone: string;
|
||||
phoneCountryCode: string;
|
||||
company?: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: "male" | "female" | "other";
|
||||
};
|
||||
errors: Record<string, string | undefined>;
|
||||
touched: Record<string, boolean | undefined>;
|
||||
@ -41,7 +43,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
>
|
||||
<Input
|
||||
value={values.sfNumber}
|
||||
onChange={(e) => setValue("sfNumber", e.target.value)}
|
||||
onChange={e => setValue("sfNumber", e.target.value)}
|
||||
onBlur={() => setTouchedField("sfNumber")}
|
||||
placeholder="e.g., AST-123456"
|
||||
className="bg-white"
|
||||
@ -56,7 +58,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
<Input
|
||||
name="given-name"
|
||||
value={values.firstName}
|
||||
onChange={(e) => setValue("firstName", e.target.value)}
|
||||
onChange={e => setValue("firstName", e.target.value)}
|
||||
onBlur={() => setTouchedField("firstName")}
|
||||
placeholder="Taro"
|
||||
autoComplete="given-name"
|
||||
@ -66,7 +68,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
<Input
|
||||
name="family-name"
|
||||
value={values.lastName}
|
||||
onChange={(e) => setValue("lastName", e.target.value)}
|
||||
onChange={e => setValue("lastName", e.target.value)}
|
||||
onBlur={() => setTouchedField("lastName")}
|
||||
placeholder="Yamada"
|
||||
autoComplete="family-name"
|
||||
@ -80,7 +82,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
name="email"
|
||||
type="email"
|
||||
value={values.email}
|
||||
onChange={(e) => setValue("email", e.target.value)}
|
||||
onChange={e => setValue("email", e.target.value)}
|
||||
onBlur={() => setTouchedField("email")}
|
||||
placeholder="taro.yamada@example.com"
|
||||
autoComplete="email"
|
||||
@ -98,7 +100,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
name="tel-country-code"
|
||||
type="tel"
|
||||
value={values.phoneCountryCode}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
// Allow + and digits only, max 5 chars
|
||||
let val = e.target.value.replace(/[^\d+]/g, "");
|
||||
if (!val.startsWith("+")) val = "+" + val.replace(/\+/g, "");
|
||||
@ -114,7 +116,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
name="tel-national"
|
||||
type="tel"
|
||||
value={values.phone}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
// Only allow digits
|
||||
const cleaned = e.target.value.replace(/\D/g, "");
|
||||
setValue("phone", cleaned);
|
||||
@ -127,12 +129,50 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
</div>
|
||||
</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) */}
|
||||
<FormField label="Company" error={getError("company")} helperText="Optional">
|
||||
<Input
|
||||
name="organization"
|
||||
value={values.company ?? ""}
|
||||
onChange={(e) => setValue("company", e.target.value)}
|
||||
onChange={e => setValue("company", e.target.value)}
|
||||
onBlur={() => setTouchedField("company")}
|
||||
placeholder="Company name"
|
||||
autoComplete="organization"
|
||||
|
||||
@ -70,6 +70,8 @@ interface ReviewStepProps {
|
||||
phoneCountryCode: string;
|
||||
sfNumber: string;
|
||||
company?: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: "male" | "female" | "other";
|
||||
address: {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
@ -140,6 +142,18 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
||||
<dd className="text-gray-900 font-medium">{values.company}</dd>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
BIN
docs/portal-guides/COMPLETE-GUIDE.docx
Normal file
BIN
docs/portal-guides/COMPLETE-GUIDE.docx
Normal file
Binary file not shown.
@ -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.
|
||||
- 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.
|
||||
## Contents
|
||||
|
||||
## 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.
|
||||
- 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).
|
||||
The portal consists of two main components:
|
||||
|
||||
## 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:
|
||||
- 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.
|
||||
- We create a WHMCS client (billing account) with the provided contact + address and store custom fields when present (Customer Number, DOB, Gender, Nationality).
|
||||
- We create the portal user, store the ID mapping (portal ↔ WHMCS ↔ Salesforce), and update the Salesforce Account with portal status + WHMCS ID.
|
||||
- Linking an existing WHMCS user:
|
||||
- Validate WHMCS credentials.
|
||||
- Read Customer Number from WHMCS custom field 198 (“Customer Number”) and find the Salesforce Account.
|
||||
- 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.
|
||||
| System | Role | Integration Method |
|
||||
| ---------------------- | --------------------------------------- | ------------------------------------ |
|
||||
| **WHMCS** | Billing system of record | REST API (API actions) |
|
||||
| **Salesforce** | CRM and order management | REST API + Change Data Capture (CDC) |
|
||||
| **Freebit** | SIM/MVNO provisioning | REST API |
|
||||
| **SFTP (fs.mvno.net)** | Call/SMS detail records | SFTP file download |
|
||||
| **PostgreSQL** | Portal user accounts and ID mappings | Direct connection |
|
||||
| **Redis** | Caching and pub/sub for realtime events | Direct connection |
|
||||
|
||||
What customers see:
|
||||
### ID Mapping
|
||||
|
||||
- Immediate validation of Customer Number; clear prompts if the account already exists.
|
||||
- Errors are phrased as actions: “Log in instead,” “Add missing billing info,” “Invalid WHMCS password.”
|
||||
The portal maintains a mapping table (`id_mappings` in PostgreSQL) linking:
|
||||
|
||||
### 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).
|
||||
- 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.
|
||||
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.
|
||||
|
||||
### 2.5) Password Reset
|
||||
---
|
||||
|
||||
- Users can request a password reset via email.
|
||||
- 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.
|
||||
## Data Ownership and Flow
|
||||
|
||||
### 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 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.
|
||||
### Salesforce Account Lookup
|
||||
|
||||
### 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.
|
||||
- Steps:
|
||||
1. Set Salesforce activation status to “Activating.”
|
||||
2. Map Salesforce order items to WHMCS products and call WHMCS AddOrder (creates billing order, invoices, subscriptions).
|
||||
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.
|
||||
```sql
|
||||
SELECT Id, Name, WH_Account__c
|
||||
FROM Account
|
||||
WHERE SF_Account_No__c = '{customerNumber}'
|
||||
```
|
||||
|
||||
### 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).
|
||||
- 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.
|
||||
### New Customer Sign-Up
|
||||
|
||||
**SSO Links**:
|
||||
**Validation Steps:**
|
||||
|
||||
- The portal generates time-limited SSO tokens (~60 seconds) that redirect users to WHMCS for payment.
|
||||
- Payment methods and gateways can be pre-selected via URL parameters.
|
||||
- SSO links can also be used to access the WHMCS client area for account management (e.g., adding payment methods).
|
||||
- The portal normalizes SSO redirect URLs to use the configured WHMCS base URL.
|
||||
1. Check if email already exists in portal `users` table
|
||||
- If exists with mapping → "You already have an account. Please sign in."
|
||||
- If exists without mapping → "Please sign in to continue setup."
|
||||
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.
|
||||
- Used for dashboard stats (active counts, etc.).
|
||||
- If mapping is missing, we return a clear error; if WHMCS is down, we ask to retry later and do not cache failures.
|
||||
1. Create WHMCS client via `AddClient` API action with:
|
||||
- Contact info: `firstname`, `lastname`, `email`, `phonenumber`
|
||||
- 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.
|
||||
- **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).
|
||||
**Rollback Behavior:**
|
||||
|
||||
**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**.
|
||||
- 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.
|
||||
### Linking Existing WHMCS Account
|
||||
|
||||
### 9) Support Cases
|
||||
**Flow:**
|
||||
|
||||
- Data source: Salesforce. Origin is "Portal Website."
|
||||
- The portal shows only cases for the customer's mapped Salesforce Account.
|
||||
- Create: requires subject/description; optional category/type and priority; sets Status = New.
|
||||
- Reads are live (no cache) so status/priority/comments are up to date.
|
||||
- If Salesforce is unavailable, we show "support system unavailable" and avoid leaking details.
|
||||
1. Customer submits WHMCS email and password
|
||||
2. Portal validates credentials via WHMCS `ValidateLogin` API action
|
||||
3. Check if WHMCS client is already mapped → "This billing account is already linked. Please sign in."
|
||||
4. Portal reads Customer Number from WHMCS custom field 198 via `GetClientsDetails`
|
||||
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.
|
||||
- **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).
|
||||
### Data Source
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
### Updates
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
### Available Fields
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
### Portal Password
|
||||
|
||||
### 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.
|
||||
- **Global catalog stream**: Receives catalog/pricebook invalidation events.
|
||||
- **Heartbeats**: Sent every 30 seconds to keep the connection alive.
|
||||
- **Connection limiting**: Each user can only have a limited number of concurrent SSE connections to prevent resource abuse.
|
||||
- **Rate limiting**: 30 connection attempts per minute to protect against reconnect storms.
|
||||
- Backed by Redis pub/sub for multi-instance delivery.
|
||||
1. Customer requests reset via email
|
||||
2. Portal generates time-limited token (stored in DB)
|
||||
3. Email sent with reset link
|
||||
4. Customer submits new password with token
|
||||
5. All existing sessions are invalidated (token blacklist)
|
||||
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).
|
||||
- Avoid partial writes: if upstream systems fail, we do not store partial data; caches are not polluted with failures.
|
||||
- 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.
|
||||
- Password reset requests: 5 per 15 minutes per IP
|
||||
- Response always: "If an account exists, a reset email has been sent" (prevents enumeration)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
- All endpoints are rate-limited to prevent abuse. Limits are tracked per IP + User-Agent combination.
|
||||
- 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.
|
||||
### API Rate Limits
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- SIM experience: existing SIM customers see family/discount SIM plans; new SIM customers see standard plans.
|
||||
- SIM management: changes to voice/network take effect immediately (~30 minutes); plan changes take effect on the 1st of the following month.
|
||||
- Call history: CSV files are available 2 months behind the current month (e.g., in November you can access September's records).
|
||||
### Rate Limit Key
|
||||
|
||||
- Composed of: IP address + User-Agent hash
|
||||
- Prevents bypass via User-Agent rotation alone
|
||||
- Uses Redis-backed `rate-limiter-flexible`
|
||||
|
||||
### 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_
|
||||
|
||||
BIN
docs/portal-guides/~$MPLETE-GUIDE.docx
Normal file
BIN
docs/portal-guides/~$MPLETE-GUIDE.docx
Normal file
Binary file not shown.
7
env/portal-backend.env.sample
vendored
7
env/portal-backend.env.sample
vendored
@ -70,6 +70,13 @@ WHMCS_BASE_URL=https://accounts.asolutions.co.jp
|
||||
WHMCS_API_IDENTIFIER=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)
|
||||
# WHMCS_QUEUE_CONCURRENCY=15
|
||||
# WHMCS_QUEUE_INTERVAL_CAP=300
|
||||
|
||||
@ -20,6 +20,10 @@ import { addressSchema, userSchema } from "../customer/schema.js";
|
||||
// ============================================================================
|
||||
|
||||
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({
|
||||
email: emailSchema,
|
||||
@ -40,7 +44,7 @@ export const signupInputSchema = z.object({
|
||||
sfNumber: z.string().min(6, "Customer number must be at least 6 characters"),
|
||||
address: addressSchema.optional(),
|
||||
nationality: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
dateOfBirth: isoDateOnlySchema.optional(),
|
||||
gender: genderEnum.optional(),
|
||||
acceptTerms: z.boolean(),
|
||||
marketingConsent: z.boolean().optional(),
|
||||
@ -91,6 +95,7 @@ export const validateSignupRequestSchema = z.object({
|
||||
*/
|
||||
export const updateCustomerProfileRequestSchema = z.object({
|
||||
// Basic profile
|
||||
email: emailSchema.optional(),
|
||||
firstname: nameSchema.optional(),
|
||||
lastname: nameSchema.optional(),
|
||||
companyname: z.string().max(100).optional(),
|
||||
|
||||
@ -7,7 +7,11 @@
|
||||
|
||||
export * from "./mapper.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
|
||||
export type { WhmcsClient, EmailPreferences, SubUser, Stats } from "../../schema.js";
|
||||
|
||||
@ -30,7 +30,11 @@ export interface WhmcsAddClientParams {
|
||||
companyname?: string;
|
||||
currency?: string;
|
||||
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;
|
||||
clientip?: string;
|
||||
notes?: string;
|
||||
|
||||
@ -13,7 +13,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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
|
||||
@ -33,7 +36,9 @@ const normalizeBoolean = (value: unknown): boolean | null | undefined => {
|
||||
if (typeof value === "number") return value === 1;
|
||||
if (typeof value === "string") {
|
||||
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;
|
||||
};
|
||||
@ -58,10 +63,18 @@ export const addressSchema = 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(),
|
||||
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(),
|
||||
country: z.string().min(1, "Country is required").max(100, "Country name is too long").trim(),
|
||||
countryCode: countryCodeSchema.optional(),
|
||||
@ -78,8 +91,7 @@ export const addressFormSchema = z.object({
|
||||
* Contains basic editable user profile fields (WHMCS field names)
|
||||
*/
|
||||
export const profileEditFormSchema = z.object({
|
||||
firstname: z.string().min(1, "First name is required").max(100).trim(),
|
||||
lastname: z.string().min(1, "Last name is required").max(100).trim(),
|
||||
email: z.string().email("Enter a valid email").trim(),
|
||||
phonenumber: z.string().optional(),
|
||||
});
|
||||
|
||||
@ -88,7 +100,7 @@ export const profileEditFormSchema = z.object({
|
||||
* Used for displaying profile information
|
||||
*/
|
||||
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
|
||||
* Internal to Providers.Whmcs namespace
|
||||
*/
|
||||
const emailPreferencesSchema = z.object({
|
||||
const emailPreferencesSchema = z
|
||||
.object({
|
||||
general: booleanLike.optional(),
|
||||
invoice: booleanLike.optional(),
|
||||
support: booleanLike.optional(),
|
||||
product: booleanLike.optional(),
|
||||
domain: booleanLike.optional(),
|
||||
affiliate: booleanLike.optional(),
|
||||
}).transform(prefs => ({
|
||||
})
|
||||
.transform(prefs => ({
|
||||
general: normalizeBoolean(prefs.general),
|
||||
invoice: normalizeBoolean(prefs.invoice),
|
||||
support: normalizeBoolean(prefs.support),
|
||||
product: normalizeBoolean(prefs.product),
|
||||
domain: normalizeBoolean(prefs.domain),
|
||||
affiliate: normalizeBoolean(prefs.affiliate),
|
||||
}));
|
||||
}));
|
||||
|
||||
/**
|
||||
* Sub-user from WHMCS
|
||||
* Internal to Providers.Whmcs namespace
|
||||
*/
|
||||
const subUserSchema = z.object({
|
||||
const subUserSchema = z
|
||||
.object({
|
||||
id: numberLike,
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
is_owner: booleanLike.optional(),
|
||||
}).transform(user => ({
|
||||
})
|
||||
.transform(user => ({
|
||||
id: Number(user.id),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
is_owner: normalizeBoolean(user.is_owner),
|
||||
}));
|
||||
}));
|
||||
|
||||
/**
|
||||
* Billing stats from WHMCS
|
||||
* Internal to Providers.Whmcs namespace
|
||||
*/
|
||||
const statsSchema = z.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean()])
|
||||
).optional();
|
||||
const statsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional();
|
||||
|
||||
const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema);
|
||||
|
||||
@ -175,9 +188,7 @@ const whmcsCustomFieldsSchema = z
|
||||
whmcsRawCustomFieldsArraySchema,
|
||||
z
|
||||
.object({
|
||||
customfield: z
|
||||
.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
|
||||
.optional(),
|
||||
customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
@ -292,6 +303,11 @@ export const userSchema = userAuthSchema.extend({
|
||||
language: z.string().nullable().optional(),
|
||||
currency_code: z.string().nullable().optional(), // WHMCS uses snake_case for this
|
||||
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
|
||||
*/
|
||||
export function profileFormToRequest(form: ProfileEditFormData): {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
phonenumber?: string;
|
||||
} {
|
||||
return {
|
||||
firstname: form.firstname.trim(),
|
||||
lastname: form.lastname.trim(),
|
||||
email: form.email.trim(),
|
||||
phonenumber: form.phonenumber?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
@ -365,7 +379,11 @@ export function combineToUser(userAuth: UserAuth, whmcsClient: WhmcsClient): Use
|
||||
lastname: whmcsClient.lastname || null,
|
||||
fullname: whmcsClient.fullname || 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,
|
||||
currency_code: whmcsClient.currency_code || null,
|
||||
address: whmcsClient.address || undefined,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
import type { OrderDetails, OrderItemDetails } from "../../contract.js";
|
||||
import { normalizeBillingCycle } from "../../helpers.js";
|
||||
import { serializeWhmcsKeyValueMap } from "../../../providers/whmcs/utils.js";
|
||||
import {
|
||||
type WhmcsOrderItem,
|
||||
type WhmcsAddOrderParams,
|
||||
@ -24,10 +25,7 @@ export interface OrderItemMappingResult {
|
||||
/**
|
||||
* Map a single order item to WHMCS format
|
||||
*/
|
||||
export function mapOrderItemToWhmcs(
|
||||
item: OrderItemDetails,
|
||||
index = 0
|
||||
): WhmcsOrderItem {
|
||||
export function mapOrderItemToWhmcs(item: OrderItemDetails, index = 0): WhmcsOrderItem {
|
||||
if (!item.product?.whmcsProductId) {
|
||||
throw new Error(`Order item ${index} missing WHMCS product ID`);
|
||||
}
|
||||
@ -45,9 +43,7 @@ export function mapOrderItemToWhmcs(
|
||||
* Map order details to WHMCS items format
|
||||
* Extracts items from OrderDetails and transforms to WHMCS API format
|
||||
*/
|
||||
export function mapOrderToWhmcsItems(
|
||||
orderDetails: OrderDetails
|
||||
): OrderItemMappingResult {
|
||||
export function mapOrderToWhmcsItems(orderDetails: OrderDetails): OrderItemMappingResult {
|
||||
if (!orderDetails.items || orderDetails.items.length === 0) {
|
||||
throw new Error("No order items provided for WHMCS mapping");
|
||||
}
|
||||
@ -125,10 +121,10 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
|
||||
quantities.push(item.quantity);
|
||||
|
||||
// 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
|
||||
customFields.push(serializeForWhmcs(item.customFields));
|
||||
customFields.push(serializeWhmcsKeyValueMap(item.customFields));
|
||||
});
|
||||
|
||||
const payload: WhmcsAddOrderPayload = {
|
||||
@ -165,32 +161,6 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
|
||||
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
|
||||
*/
|
||||
|
||||
@ -142,3 +142,41 @@ export function getCustomFieldValue(
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user