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_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(),

View File

@ -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", {

View File

@ -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)}`);
}
}
}

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 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),

View File

@ -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,

View File

@ -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 && (

View File

@ -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">

View File

@ -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"

View File

@ -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>

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.
- 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_

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_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

View File

@ -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(),

View File

@ -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";

View File

@ -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;

View File

@ -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,14 +138,16 @@ 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),
@ -146,12 +160,14 @@ const emailPreferencesSchema = z.object({
* 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,
@ -162,10 +178,7 @@ const subUserSchema = z.object({
* 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,

View File

@ -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
*/

View File

@ -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");
}