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 00000000..fc1a1a9f Binary files /dev/null and b/docs/portal-guides/COMPLETE-GUIDE.docx differ 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 00000000..2ab65705 Binary files /dev/null and b/docs/portal-guides/~$MPLETE-GUIDE.docx differ 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"); +}