From 545e62b8a143995858c5e494ff80337ee10fc84b Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 15 Dec 2025 17:29:28 +0900 Subject: [PATCH] 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. --- apps/bff/src/core/config/env.validation.ts | 5 + .../workflows/signup-workflow.service.ts | 17 +- .../users/infra/user-auth.repository.ts | 13 + .../users/infra/user-profile.service.ts | 73 +- .../features/account/hooks/useProfileEdit.ts | 9 + .../account/views/ProfileContainer.tsx | 96 ++- .../auth/components/SignupForm/SignupForm.tsx | 56 +- .../SignupForm/steps/AccountStep.tsx | 54 +- .../SignupForm/steps/ReviewStep.tsx | 14 + docs/portal-guides/COMPLETE-GUIDE.docx | Bin 0 -> 23370 bytes docs/portal-guides/COMPLETE-GUIDE.md | 756 ++++++++++++++---- docs/portal-guides/~$MPLETE-GUIDE.docx | Bin 0 -> 162 bytes env/portal-backend.env.sample | 7 + packages/domain/auth/schema.ts | 7 +- .../domain/customer/providers/whmcs/index.ts | 8 +- .../customer/providers/whmcs/raw.types.ts | 6 +- packages/domain/customer/schema.ts | 130 +-- .../domain/orders/providers/whmcs/mapper.ts | 54 +- packages/domain/providers/whmcs/utils.ts | 38 + 19 files changed, 987 insertions(+), 356 deletions(-) create mode 100644 docs/portal-guides/COMPLETE-GUIDE.docx create mode 100644 docs/portal-guides/~$MPLETE-GUIDE.docx diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index bb0be8fe..abdb2964 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -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(), diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index 074743b1..bce6f5f2 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -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( - "WHMCS_CUSTOMER_NUMBER_FIELD_ID", - "198" + "WHMCS_CUSTOMER_NUMBER_FIELD_ID" ); const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); - const customfields: Record = {}; - 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 = {}; + 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", { diff --git a/apps/bff/src/modules/users/infra/user-auth.repository.ts b/apps/bff/src/modules/users/infra/user-auth.repository.ts index e109dd66..58e398d3 100644 --- a/apps/bff/src/modules/users/infra/user-auth.repository.ts +++ b/apps/bff/src/modules/users/infra/user-auth.repository.ts @@ -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 { + 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)}`); + } + } } diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index fe244b8b..ff8a6f01 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -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( + "WHMCS_CUSTOMER_NUMBER_FIELD_ID", + "198" + ); + const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); + const genderFieldId = this.configService.get("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), diff --git a/apps/portal/src/features/account/hooks/useProfileEdit.ts b/apps/portal/src/features/account/hooks/useProfileEdit.ts index 7d25f285..d8213329 100644 --- a/apps/portal/src/features/account/hooks/useProfileEdit.ts +++ b/apps/portal/src/features/account/hooks/useProfileEdit.ts @@ -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, diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index 389a87ae..f0b82d95 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -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() {
- {editingProfile ? ( - 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" - /> - ) : ( -

+

+

{user?.firstname || Not provided}

- )} +

+ Name cannot be changed from the portal. +

+
- {editingProfile ? ( - 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" - /> - ) : ( -

+

+

{user?.lastname || Not provided}

- )} +

+ Name cannot be changed from the portal. +

+
-
-
-

{user?.email}

+ {editingProfile ? ( + 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" + /> + ) : ( +
+
+

{user?.email}

+
+

+ Email can be updated from the portal. +

+ )} +
+ +
+ +
+

+ {user?.sfNumber || Not available} +

+

Customer number is read-only.

+
+
+ +
+ +
+

+ {user?.dateOfBirth || ( + Not provided + )} +

- Email cannot be changed from the portal. + Date of birth is stored in billing profile.

@@ -253,6 +279,16 @@ export default function ProfileContainer() {

)}
+ +
+ +
+

+ {user?.gender || Not provided} +

+

Gender is stored in billing profile.

+
+
{editingProfile && ( diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index 267b977a..3d3c511d 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -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 -> = { +const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array> = { 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 && ( - - {error} - + {error} )}
diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx index fe4381e7..a03ca98f 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx @@ -17,6 +17,8 @@ interface AccountStepProps { phone: string; phoneCountryCode: string; company?: string; + dateOfBirth?: string; + gender?: "male" | "female" | "other"; }; errors: Record; touched: Record; @@ -41,7 +43,7 @@ export function AccountStep({ form }: AccountStepProps) { > 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) { 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) { 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) {
+ {/* DOB + Gender (Optional WHMCS custom fields) */} +
+ + setValue("dateOfBirth", e.target.value || undefined)} + onBlur={() => setTouchedField("dateOfBirth")} + autoComplete="bday" + /> + + + + + +
+ {/* Company (Optional) */} setValue("company", e.target.value)} + onChange={e => setValue("company", e.target.value)} onBlur={() => setTouchedField("company")} placeholder="Company name" autoComplete="organization" diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx index 7538acb0..5bb1d26d 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx @@ -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) {
{values.company}
)} + {values.dateOfBirth && ( +
+
Date of Birth
+
{values.dateOfBirth}
+
+ )} + {values.gender && ( +
+
Gender
+
{values.gender}
+
+ )} diff --git a/docs/portal-guides/COMPLETE-GUIDE.docx b/docs/portal-guides/COMPLETE-GUIDE.docx new file mode 100644 index 0000000000000000000000000000000000000000..fc1a1a9f5c9362e2df71f6fb158a19a5fc86588d GIT binary patch literal 23370 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 HuV4QI7(jgM literal 0 HcmV?d00001 diff --git a/docs/portal-guides/COMPLETE-GUIDE.md b/docs/portal-guides/COMPLETE-GUIDE.md index e9b238f8..c250c7c5 100644 --- a/docs/portal-guides/COMPLETE-GUIDE.md +++ b/docs/portal-guides/COMPLETE-GUIDE.md @@ -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_ diff --git a/docs/portal-guides/~$MPLETE-GUIDE.docx b/docs/portal-guides/~$MPLETE-GUIDE.docx new file mode 100644 index 0000000000000000000000000000000000000000..2ab65705e41a590ce9b2308a960b8bf52cc94dd0 GIT binary patch literal 162 zcmWd*Da|b{N=hs$R`5$KO3W)MtxRMf2!t6z7)lv(fw+hvi6Ie47BeU?_<>o840#MC zK$%J)4;?T;1-zmmjOjD7l&9t1VdM~OI#v6Efnn0*(hv}zfuVs3NHBE1JG}z{RV5v- literal 0 HcmV?d00001 diff --git a/env/portal-backend.env.sample b/env/portal-backend.env.sample index 3f82a7d4..dcc4ce68 100644 --- a/env/portal-backend.env.sample +++ b/env/portal-backend.env.sample @@ -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 diff --git a/packages/domain/auth/schema.ts b/packages/domain/auth/schema.ts index 963fe2ab..b5a4d50a 100644 --- a/packages/domain/auth/schema.ts +++ b/packages/domain/auth/schema.ts @@ -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(), diff --git a/packages/domain/customer/providers/whmcs/index.ts b/packages/domain/customer/providers/whmcs/index.ts index c1c27f2c..8f82f013 100644 --- a/packages/domain/customer/providers/whmcs/index.ts +++ b/packages/domain/customer/providers/whmcs/index.ts @@ -1,13 +1,17 @@ /** * WHMCS Provider - * + * * Handles mapping from WHMCS API to domain types. * Exports transformation functions and raw API types (request/response). */ 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"; diff --git a/packages/domain/customer/providers/whmcs/raw.types.ts b/packages/domain/customer/providers/whmcs/raw.types.ts index 55bd52ae..5e5a1a28 100644 --- a/packages/domain/customer/providers/whmcs/raw.types.ts +++ b/packages/domain/customer/providers/whmcs/raw.types.ts @@ -30,7 +30,11 @@ export interface WhmcsAddClientParams { companyname?: string; currency?: string; groupid?: number; - customfields?: Record; + /** + * 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; diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index 546e66b3..78541241 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -1,9 +1,9 @@ /** * Customer Domain - Schemas - * + * * Zod validation schemas for customer domain types. * Pattern matches billing and subscriptions domains. - * + * * Architecture: * - UserAuth: Auth state from portal database (Prisma) * - WhmcsClient: Full WHMCS data (raw field names, internal to providers) @@ -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) }); // ============================================================================ @@ -97,10 +109,10 @@ export const profileDisplayDataSchema = profileEditFormSchema.extend({ /** * UserAuth - Authentication state from portal database - * + * * Source: Portal database (Prisma) * Provider: customer/providers/portal/ - * + * * Contains ONLY auth-related fields: * - User ID, email, role * - Email verification status @@ -126,46 +138,47 @@ export const userAuthSchema = z.object({ * Email preferences from WHMCS * Internal to Providers.Whmcs namespace */ -const emailPreferencesSchema = z.object({ - general: booleanLike.optional(), - invoice: booleanLike.optional(), - support: booleanLike.optional(), - product: booleanLike.optional(), - domain: booleanLike.optional(), - affiliate: booleanLike.optional(), -}).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), -})); +const emailPreferencesSchema = z + .object({ + general: booleanLike.optional(), + invoice: booleanLike.optional(), + support: booleanLike.optional(), + product: booleanLike.optional(), + domain: booleanLike.optional(), + affiliate: booleanLike.optional(), + }) + .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({ - id: numberLike, - name: z.string(), - email: z.string(), - is_owner: booleanLike.optional(), -}).transform(user => ({ - id: Number(user.id), - name: user.name, - email: user.email, - is_owner: normalizeBoolean(user.is_owner), -})); +const subUserSchema = z + .object({ + id: numberLike, + name: z.string(), + email: z.string(), + is_owner: booleanLike.optional(), + }) + .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(), ]) @@ -196,10 +207,10 @@ const whmcsUsersSchema = z /** * WhmcsClient - Full WHMCS client data - * + * * Raw WHMCS structure with field names as they come from the API. * Internal to Providers.Whmcs namespace - not exported at top level. - * + * * Includes: * - Profile data (firstname, lastname, companyname, etc.) * - Billing info (currency_code, defaultgateway, status) @@ -276,10 +287,10 @@ export const whmcsClientSchema = whmcsRawClientSchema /** * User - Complete user profile for API responses - * + * * Composition: UserAuth (portal DB) + WhmcsClient (WHMCS) * Field names match WHMCS API exactly (no transformation) - * + * * Use combineToUser() helper to construct from sources */ export const userSchema = userAuthSchema.extend({ @@ -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,23 +343,21 @@ 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, }; } /** * Combine UserAuth and WhmcsClient into User - * + * * This is the single source of truth for constructing User from its sources. * No field name transformation - User schema uses WHMCS field names directly. - * + * * @param userAuth - Authentication state from portal database * @param whmcsClient - Full client data from WHMCS * @returns User object for API responses with WHMCS field names @@ -359,13 +373,17 @@ export function combineToUser(userAuth: UserAuth, whmcsClient: WhmcsClient): Use lastLoginAt: userAuth.lastLoginAt, createdAt: userAuth.createdAt, updatedAt: userAuth.updatedAt, - + // Profile from WHMCS (no transformation - keep field names as-is) firstname: whmcsClient.firstname || null, 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, diff --git a/packages/domain/orders/providers/whmcs/mapper.ts b/packages/domain/orders/providers/whmcs/mapper.ts index 72806778..d8416325 100644 --- a/packages/domain/orders/providers/whmcs/mapper.ts +++ b/packages/domain/orders/providers/whmcs/mapper.ts @@ -1,11 +1,12 @@ /** * Orders Domain - WHMCS Provider Mapper - * + * * Transforms normalized order data to WHMCS API format. */ 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"); } @@ -59,7 +55,7 @@ export function mapOrderToWhmcsItems( orderDetails.items.forEach((item, index) => { const mapped = mapOrderItemToWhmcs(item, index); whmcsItems.push(mapped); - + if (mapped.billingCycle === "monthly") { serviceItems++; } else if (mapped.billingCycle === "onetime") { @@ -80,17 +76,17 @@ export function mapOrderToWhmcsItems( /** * Build WHMCS AddOrder API payload from parameters * Converts structured params into WHMCS API array format - * + * * WHMCS AddOrder API Documentation: * @see https://developers.whmcs.com/api-reference/addorder/ - * + * * Required Parameters: * - clientid (int): The client ID * - paymentmethod (string): Payment method (e.g. "stripe", "paypal", "mailin") * - pid (int[]): Array of product IDs * - qty (int[]): Array of product quantities (REQUIRED! Without this, no products are added) * - billingcycle (string[]): Array of billing cycles (e.g. "monthly", "onetime") - * + * * Optional Parameters: * - promocode (string): Promotion code to apply * - noinvoice (bool): Don't create invoice @@ -98,7 +94,7 @@ export function mapOrderToWhmcsItems( * - noemail (bool): Don't send order confirmation email * - configoptions (string[]): Base64 encoded serialized arrays of config options * - customfields (string[]): Base64 encoded serialized arrays of custom fields - * + * * Response Fields: * - result: "success" or "error" * - orderid: The created order ID @@ -106,7 +102,7 @@ export function mapOrderToWhmcsItems( * - addonids: Comma-separated addon IDs created * - domainids: Comma-separated domain IDs created * - invoiceid: The invoice ID created - * + * * Common Errors: * - "No items added to cart so order cannot proceed" - Missing or invalid qty parameter * - "Client ID Not Found" - Invalid client ID @@ -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 { - 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 */ diff --git a/packages/domain/providers/whmcs/utils.ts b/packages/domain/providers/whmcs/utils.ts index 80563255..a6fabd9e 100644 --- a/packages/domain/providers/whmcs/utils.ts +++ b/packages/domain/providers/whmcs/utils.ts @@ -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 { + 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"); +}