Refactor environment configuration and enhance order processing logic

- Updated SF_PUBSUB_NUM_REQUESTED in environment configuration to improve flow control.
- Enhanced CatalogCdcSubscriber and OrderCdcSubscriber to utilize a dynamic numRequested value for subscriptions, improving event handling.
- Removed deprecated WHMCS API access key configurations from WhmcsConfigService to streamline integration.
- Improved error handling and logging in various services for better operational insights.
- Refactored currency service to centralize fallback currency logic, ensuring consistent currency handling across the application.
This commit is contained in:
barsa 2025-11-17 10:31:33 +09:00
parent cbaa878000
commit d943d04754
45 changed files with 824 additions and 560 deletions

View File

@ -227,7 +227,7 @@ SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
SF_EVENTS_REPLAY=LATEST # or ALL for retention replay
SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443
SF_PUBSUB_NUM_REQUESTED=50 # flow control window
SF_PUBSUB_NUM_REQUESTED=25 # flow control window
```
- Verify subscriber status: `GET /health/sf-events`

View File

@ -127,7 +127,8 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
"^@/(.*)$": "<rootDir>/$1",
"^@bff/(.*)$": "<rootDir>/$1"
},
"passWithNoTests": true
}

View File

@ -53,17 +53,11 @@ export const envSchema = z.object({
WHMCS_BASE_URL: z.string().url().optional(),
WHMCS_API_IDENTIFIER: z.string().optional(),
WHMCS_API_SECRET: z.string().optional(),
WHMCS_API_ACCESS_KEY: z.string().optional(),
WHMCS_WEBHOOK_SECRET: z.string().optional(),
WHMCS_DEV_BASE_URL: z.string().url().optional(),
WHMCS_DEV_API_IDENTIFIER: z.string().optional(),
WHMCS_DEV_API_SECRET: z.string().optional(),
WHMCS_DEV_API_ACCESS_KEY: z.string().optional(),
WHMCS_DEV_WEBHOOK_SECRET: z.string().optional(),
WHMCS_DEV_ADMIN_USERNAME: z.string().optional(),
WHMCS_ADMIN_USERNAME: z.string().optional(),
WHMCS_ADMIN_PASSWORD_MD5: z.string().optional(),
WHMCS_ADMIN_PASSWORD_HASH: z.string().optional(),
SF_LOGIN_URL: z.string().url().optional(),
SF_USERNAME: z.string().optional(),
@ -87,9 +81,8 @@ export const envSchema = z.object({
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
SF_CATALOG_EVENT_CHANNEL: z.string().default("/event/Product_and_Pricebook_Change__e"),
SF_ACCOUNT_EVENT_CHANNEL: z.string().default("/event/Account_Internet_Eligibility_Update__e"),
SF_ORDER_EVENT_CHANNEL: z.string().optional(),
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
SF_PUBSUB_NUM_REQUESTED: z.string().default("50"),
SF_PUBSUB_NUM_REQUESTED: z.string().default("25"),
SF_PUBSUB_QUEUE_MAX: z.string().default("100"),
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),

View File

@ -1,5 +1,5 @@
import type { Response } from "express";
import { Logger } from "nestjs-pino";
import type { Logger } from "nestjs-pino";
import { CsrfController } from "./csrf.controller";
import type { AuthenticatedRequest } from "./csrf.controller";
import type { CsrfService, CsrfTokenData } from "../services/csrf.service";

View File

@ -31,13 +31,16 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private productChannel: string | null = null;
private pricebookChannel: string | null = null;
private accountChannel: string | null = null;
private readonly numRequested: number;
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
private readonly catalogCache: CatalogCacheService,
@Inject(Logger) private readonly logger: Logger
) {}
) {
this.numRequested = this.resolveNumRequested();
}
async onModuleInit(): Promise<void> {
const productChannel =
@ -54,14 +57,16 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.productChannel = productChannel;
await client.subscribe(
productChannel,
this.handleProductEvent.bind(this, productChannel)
this.handleProductEvent.bind(this, productChannel),
this.numRequested
);
this.logger.log("Subscribed to Product2 CDC channel", { productChannel });
this.pricebookChannel = pricebookChannel;
await client.subscribe(
pricebookChannel,
this.handlePricebookEvent.bind(this, pricebookChannel)
this.handlePricebookEvent.bind(this, pricebookChannel),
this.numRequested
);
this.logger.log("Subscribed to PricebookEntry CDC channel", { pricebookChannel });
@ -69,7 +74,8 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.accountChannel = accountChannel;
await client.subscribe(
accountChannel,
this.handleAccountEvent.bind(this, accountChannel)
this.handleAccountEvent.bind(this, accountChannel),
this.numRequested
);
this.logger.log("Subscribed to account eligibility channel", { accountChannel });
}
@ -110,7 +116,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
const client = new ctor({
authType: "OAuth",
authType: "user-supplied",
accessToken,
instanceUrl,
pubSubEndpoint,
@ -121,15 +127,29 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return client;
}
private async loadPubSubCtor(): Promise<PubSubCtor> {
private loadPubSubCtor(): Promise<PubSubCtor> {
if (!this.pubSubCtor) {
const ctor = (PubSubApiClientPkg as { default?: PubSubCtor }).default;
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubCtor) ?? null;
const maybeDefault = (PubSubApiClientPkg as { default?: PubSubCtor }).default ?? null;
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
}
return this.pubSubCtor;
return Promise.resolve(this.pubSubCtor);
}
private resolveNumRequested(): number {
const raw = this.config.get<string>("SF_PUBSUB_NUM_REQUESTED") ?? "25";
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logger.warn("Invalid SF_PUBSUB_NUM_REQUESTED value; defaulting to 25", {
rawValue: raw,
});
return 25;
}
return parsed;
}
private async handleProductEvent(
@ -301,4 +321,3 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return undefined;
}
}

View File

@ -55,6 +55,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private pubSubCtor: PubSubCtor | null = null;
private orderChannel: string | null = null;
private orderItemChannel: string | null = null;
private readonly numRequested: number;
// Internal fields that are updated by fulfillment process - ignore these
private readonly INTERNAL_FIELDS = new Set([
@ -83,7 +84,9 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private readonly ordersCache: OrdersCacheService,
private readonly provisioningQueue: ProvisioningQueueService,
@Inject(Logger) private readonly logger: Logger
) {}
) {
this.numRequested = this.resolveNumRequested();
}
async onModuleInit(): Promise<void> {
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
@ -99,22 +102,29 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() ||
"/data/OrderItemChangeEvent";
this.logger.log("Initializing Salesforce Order CDC subscriber", {
orderChannel,
orderItemChannel,
});
try {
const client = await this.ensureClient();
this.orderChannel = orderChannel;
await client.subscribe(
await this.subscribeWithDiagnostics(
client,
orderChannel,
this.handleOrderEvent.bind(this, orderChannel)
this.handleOrderEvent.bind(this, orderChannel),
"order"
);
this.logger.log("Subscribed to Order CDC channel", { orderChannel });
this.orderItemChannel = orderItemChannel;
await client.subscribe(
await this.subscribeWithDiagnostics(
client,
orderItemChannel,
this.handleOrderItemEvent.bind(this, orderItemChannel)
this.handleOrderItemEvent.bind(this, orderItemChannel),
"order_item"
);
this.logger.log("Subscribed to OrderItem CDC channel", { orderItemChannel });
} catch (error) {
this.logger.warn("Failed to initialize order CDC subscriber", {
error: error instanceof Error ? error.message : String(error),
@ -152,7 +162,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
this.config.get<string>("SF_PUBSUB_ENDPOINT") || "api.pubsub.salesforce.com:7443";
const client = new ctor({
authType: "OAuth",
authType: "user-supplied",
accessToken,
instanceUrl,
pubSubEndpoint,
@ -163,17 +173,64 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return client;
}
private async loadPubSubCtor(): Promise<PubSubCtor> {
if (!this.pubSubCtor) {
const ctor = (PubSubApiClientPkg as { default?: PubSubCtor }).default;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
private async subscribeWithDiagnostics(
client: PubSubClient,
channel: string,
handler: PubSubCallback,
label: string
): Promise<void> {
this.logger.log("Attempting Salesforce CDC subscription", {
channel,
label,
numRequested: this.numRequested,
});
try {
await client.subscribe(channel, handler, this.numRequested);
this.logger.log("Successfully subscribed to Salesforce CDC channel", {
channel,
label,
numRequested: this.numRequested,
});
} catch (error) {
this.logger.error("Salesforce CDC subscription failed", {
channel,
label,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
private async loadPubSubCtor(): Promise<PubSubCtor> {
if (this.pubSubCtor) {
return this.pubSubCtor;
}
const maybeCtor = (PubSubApiClientPkg as unknown as PubSubCtor) ?? null;
const maybeDefault = (PubSubApiClientPkg as { default?: PubSubCtor }).default ?? null;
const ctor = typeof maybeCtor === "function" ? maybeCtor : maybeDefault;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
return this.pubSubCtor;
}
private resolveNumRequested(): number {
const raw = this.config.get<string>("SF_PUBSUB_NUM_REQUESTED") ?? "25";
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logger.warn("Invalid SF_PUBSUB_NUM_REQUESTED value; defaulting to 25", {
rawValue: raw,
});
return 25;
}
return parsed;
}
/**
* Handle Order CDC events
* Only invalidate cache if customer-facing fields changed
@ -187,12 +244,25 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const entityName = this.extractStringField(payload, ["entityName"]);
const changeType = this.extractStringField(payload, ["changeType"]);
const header = payload ? this.extractChangeEventHeader(payload) : undefined;
const entityName =
this.extractStringField(payload, ["entityName"]) ||
(typeof header?.entityName === "string" ? header.entityName : undefined);
const changeType =
this.extractStringField(payload, ["changeType"]) ||
(typeof header?.changeType === "string" ? header.changeType : undefined);
const changedFields = this.extractChangedFields(payload);
// Extract Order ID
const orderId = this.extractStringField(payload, ["Id", "OrderId"]);
let orderId = this.extractStringField(payload, ["Id", "OrderId"]);
if (!orderId && header && Array.isArray(header.recordIds) && header.recordIds.length > 0) {
const firstId = header.recordIds.find(
(value): value is string => typeof value === "string" && value.trim().length > 0
);
if (firstId) {
orderId = firstId.trim();
}
}
const accountId = this.extractStringField(payload, ["AccountId"]);
if (!orderId) {
@ -455,12 +525,21 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private extractChangeEventHeader(
payload: Record<string, unknown>
): { changedFields?: unknown } | undefined {
): {
changedFields?: unknown;
recordIds?: unknown;
entityName?: unknown;
changeType?: unknown;
} | undefined {
const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") {
return header as { changedFields?: unknown };
return header as {
changedFields?: unknown;
recordIds?: unknown;
entityName?: unknown;
changeType?: unknown;
};
}
return undefined;
}
}

View File

@ -9,11 +9,9 @@ import type { WhmcsApiConfig } from "../types/connection.types";
@Injectable()
export class WhmcsConfigService {
private readonly config: WhmcsApiConfig;
private readonly accessKey?: string;
constructor(private readonly configService: ConfigService) {
this.config = this.loadConfiguration();
this.accessKey = this.loadAccessKey();
}
/**
@ -23,13 +21,6 @@ export class WhmcsConfigService {
return { ...this.config };
}
/**
* Get the API access key if available
*/
getAccessKey(): string | undefined {
return this.accessKey;
}
/**
* Get the base URL for WHMCS API
*/
@ -81,16 +72,6 @@ export class WhmcsConfigService {
};
}
/**
* Load API access key
*/
private loadAccessKey(): string | undefined {
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
const isDev = nodeEnv !== "production";
return this.getFirst([isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined, "WHMCS_API_ACCESS_KEY"]);
}
/**
* Helper: read the first defined value across a list of keys
*/

View File

@ -334,7 +334,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
timeout: config.timeout,
retryAttempts: config.retryAttempts,
retryDelay: config.retryDelay,
hasAccessKey: Boolean(this.configService.getAccessKey()),
};
}

View File

@ -189,46 +189,54 @@ export class WhmcsHttpClientService {
continue;
}
const serialized = this.serializeParamValue(value);
formData.append(key, serialized);
this.appendFormParam(formData, key, value);
}
return formData.toString();
}
private serializeParamValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void {
if (Array.isArray(value)) {
return value.map(entry => this.serializeParamValue(entry)).join(",");
value.forEach((entry, index) => {
this.appendFormParam(formData, `${key}[${index}]`, entry);
});
return;
}
if (typeof value === "object" && value !== null) {
if (value && typeof value === "object" && !(value instanceof Date)) {
// WHMCS does not accept nested objects; serialize to JSON for logging/debug.
try {
return JSON.stringify(value);
formData.append(key, JSON.stringify(value));
return;
} catch {
return Object.prototype.toString.call(value);
formData.append(key, Object.prototype.toString.call(value));
return;
}
}
formData.append(key, this.serializeScalarParam(value));
}
private serializeScalarParam(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "symbol") {
return value.description ? `Symbol(${value.description})` : "Symbol()";
}
if (typeof value === "function") {
return value.name ? `[Function ${value.name}]` : "[Function anonymous]";
}
return Object.prototype.toString.call(value);
}

View File

@ -1,14 +1,20 @@
import { Injectable, Inject, OnModuleInit } from "@nestjs/common";
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import type { WhmcsCurrenciesResponse, WhmcsCurrency } from "@customer-portal/domain/billing";
import {
FALLBACK_CURRENCY,
type WhmcsCurrenciesResponse,
type WhmcsCurrency,
} from "@customer-portal/domain/billing";
@Injectable()
export class WhmcsCurrencyService implements OnModuleInit {
export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
private defaultCurrency: WhmcsCurrency | null = null;
private currencies: WhmcsCurrency[] = [];
private readonly REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
private refreshTimer: NodeJS.Timeout | null = null;
constructor(
@Inject(Logger) private readonly logger: Logger,
@ -35,6 +41,15 @@ export class WhmcsCurrencyService implements OnModuleInit {
});
// Set fallback default
this.setFallbackCurrency();
} finally {
this.startRefreshLoop();
}
}
onModuleDestroy(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
@ -42,16 +57,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
* Set fallback currency configuration when WHMCS is not available
*/
private setFallbackCurrency(): void {
this.defaultCurrency = {
id: 1,
code: "JPY",
prefix: "¥",
suffix: "",
format: "1",
rate: "1.00000",
};
this.currencies = [this.defaultCurrency];
const fallback = { ...FALLBACK_CURRENCY };
this.defaultCurrency = fallback;
this.currencies = [fallback];
this.logger.log("Using fallback currency configuration", {
defaultCurrency: this.defaultCurrency.code,
@ -62,16 +70,7 @@ export class WhmcsCurrencyService implements OnModuleInit {
* Get the default currency (first currency from WHMCS or JPY fallback)
*/
getDefaultCurrency(): WhmcsCurrency {
return (
this.defaultCurrency || {
id: 1,
code: "JPY",
prefix: "¥",
suffix: "",
format: "1",
rate: "1.00000",
}
);
return this.defaultCurrency ? { ...this.defaultCurrency } : { ...FALLBACK_CURRENCY };
}
/**
@ -207,6 +206,29 @@ export class WhmcsCurrencyService implements OnModuleInit {
* Refresh currencies from WHMCS (can be called manually if needed)
*/
async refreshCurrencies(): Promise<void> {
await this.loadCurrencies();
try {
await this.loadCurrencies();
} catch (error) {
this.logger.error("Currency refresh failed", {
error: getErrorMessage(error),
});
if (!this.defaultCurrency || this.currencies.length === 0) {
this.setFallbackCurrency();
}
}
}
private startRefreshLoop(): void {
if (this.refreshTimer) {
return;
}
this.refreshTimer = setInterval(() => {
void this.refreshCurrencies();
}, this.REFRESH_INTERVAL_MS);
if (typeof this.refreshTimer.unref === "function") {
this.refreshTimer.unref();
}
}
}

View File

@ -4,16 +4,19 @@ import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs
import { getErrorMessage } from "@bff/core/utils/error.util";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions";
import type { WhmcsOrderItem, WhmcsAddOrderParams } from "@customer-portal/domain/orders";
import { Providers } from "@customer-portal/domain/orders";
import type {
WhmcsOrderItem,
WhmcsAddOrderParams,
WhmcsAddOrderResponse,
WhmcsOrderResult,
} from "@customer-portal/domain/orders";
import {
Providers,
whmcsAddOrderResponseSchema,
whmcsAcceptOrderResponseSchema,
} from "@customer-portal/domain/orders";
export type { WhmcsOrderItem, WhmcsAddOrderParams };
export interface WhmcsOrderResult {
orderId: number;
invoiceId?: number;
serviceIds: number[];
}
export type { WhmcsOrderItem, WhmcsAddOrderParams, WhmcsOrderResult };
@Injectable()
export class WhmcsOrderService {
@ -30,7 +33,7 @@ export class WhmcsOrderService {
* Success: { orderid, productids, serviceids, addonids, domainids, invoiceid }
* Error: Thrown by HTTP client before returning
*/
async addOrder(params: WhmcsAddOrderParams): Promise<{ orderId: number }> {
async addOrder(params: WhmcsAddOrderParams): Promise<WhmcsOrderResult> {
this.logger.log("Creating WHMCS order", {
clientId: params.clientId,
itemCount: params.items.length,
@ -57,7 +60,9 @@ export class WhmcsOrderService {
// Call WHMCS AddOrder API
// Note: The HTTP client throws errors automatically if result === "error"
// So we only get here if the request was successful
const response = (await this.connection.addOrder(addOrderPayload)) as Record<string, unknown>;
const response = (await this.connection.addOrder(
addOrderPayload
)) as WhmcsAddOrderResponse;
// Log the full response for debugging
this.logger.debug("WHMCS AddOrder response", {
@ -66,30 +71,32 @@ export class WhmcsOrderService {
sfOrderId: params.sfOrderId,
});
// Extract order ID from response
const orderId = parseInt(response.orderid as string, 10);
if (!orderId || isNaN(orderId)) {
this.logger.error("WHMCS AddOrder returned invalid order ID", {
response,
orderidValue: response.orderid,
orderidType: typeof response.orderid,
const parsedResponse = whmcsAddOrderResponseSchema.safeParse(response);
if (!parsedResponse.success) {
this.logger.error("WHMCS AddOrder response failed validation", {
clientId: params.clientId,
sfOrderId: params.sfOrderId,
issues: parsedResponse.error.flatten(),
rawResponse: response,
});
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
throw new WhmcsOperationException("WHMCS AddOrder response was invalid", {
response,
});
}
const normalizedResult = this.toWhmcsOrderResult(parsedResponse.data);
this.logger.log("WHMCS order created successfully", {
orderId,
invoiceId: response.invoiceid,
serviceIds: response.serviceids,
orderId: normalizedResult.orderId,
invoiceId: normalizedResult.invoiceId,
serviceIds: normalizedResult.serviceIds,
addonIds: normalizedResult.addonIds,
domainIds: normalizedResult.domainIds,
clientId: params.clientId,
sfOrderId: params.sfOrderId,
});
return { orderId };
return normalizedResult;
} catch (error) {
// Enhanced error logging with full context
this.logger.error("Failed to create WHMCS order", {
@ -113,7 +120,7 @@ export class WhmcsOrderService {
* Success: { orderid, invoiceid, serviceids, addonids, domainids }
* Error: Thrown by HTTP client before returning
*/
async acceptOrder(orderId: number, sfOrderId?: string): Promise<WhmcsOrderResult> {
async acceptOrder(orderId: number, sfOrderId?: string): Promise<void> {
this.logger.log("Accepting WHMCS order", {
orderId,
sfOrderId,
@ -132,28 +139,24 @@ export class WhmcsOrderService {
sfOrderId,
});
// Extract service IDs from response
const serviceIds: number[] = [];
if (response.serviceids) {
// serviceids can be a string of comma-separated IDs
const ids = (response.serviceids as string).toString().split(",");
serviceIds.push(...ids.map((id: string) => parseInt(id.trim(), 10)).filter(Boolean));
const parsedResponse = whmcsAcceptOrderResponseSchema.safeParse(response);
if (!parsedResponse.success) {
this.logger.error("WHMCS AcceptOrder response failed validation", {
orderId,
sfOrderId,
issues: parsedResponse.error.flatten(),
rawResponse: response,
});
throw new WhmcsOperationException("WHMCS AcceptOrder response was invalid", {
response,
});
}
const result: WhmcsOrderResult = {
orderId,
invoiceId: response.invoiceid ? parseInt(response.invoiceid as string, 10) : undefined,
serviceIds,
};
this.logger.log("WHMCS order accepted successfully", {
orderId,
invoiceId: result.invoiceId,
serviceCount: serviceIds.length,
invoiceId: parsedResponse.data.invoiceid,
sfOrderId,
});
return result;
} catch (error) {
// Enhanced error logging with full context
this.logger.error("Failed to accept WHMCS order", {
@ -238,4 +241,32 @@ export class WhmcsOrderService {
return payload as Record<string, unknown>;
}
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {
const orderId = parseInt(String(response.orderid), 10);
if (!orderId || Number.isNaN(orderId)) {
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
response,
});
}
return {
orderId,
invoiceId: response.invoiceid ? parseInt(String(response.invoiceid), 10) : undefined,
serviceIds: this.parseDelimitedIds(response.serviceids),
addonIds: this.parseDelimitedIds(response.addonids),
domainIds: this.parseDelimitedIds(response.domainids),
};
}
private parseDelimitedIds(value?: string): number[] {
if (!value) {
return [];
}
return value
.toString()
.split(",")
.map(entry => parseInt(entry.trim(), 10))
.filter(id => !Number.isNaN(id));
}
}

View File

@ -297,8 +297,7 @@ export class AuthController {
await this.authFacade.resetPassword(body.token, body.password);
// Clear auth cookies after password reset to force re-login
res.clearCookie("access_token", { httpOnly: true, sameSite: "lax" });
res.clearCookie("refresh_token", { httpOnly: true, sameSite: "lax" });
this.clearAuthCookies(res);
return { message: "Password reset successful" };
}

View File

@ -28,7 +28,7 @@ export class CatalogController {
@Get("internet/plans")
@Throttle({ default: { limit: 20, ttl: 60 } }) // 20 requests per minute
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
@Header("Cache-Control", "private, max-age=300") // Personalised responses: prevent shared caching
async getInternetPlans(@Request() req: RequestWithUser): Promise<{
plans: InternetPlanCatalogItem[];
installations: InternetInstallationCatalogItem[];
@ -63,7 +63,7 @@ export class CatalogController {
@Get("sim/plans")
@Throttle({ default: { limit: 20, ttl: 60 } }) // 20 requests per minute
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
@Header("Cache-Control", "private, max-age=300") // Personalised responses: prevent shared caching
async getSimCatalogData(@Request() req: RequestWithUser): Promise<SimCatalogCollection> {
const userId = req.user?.id;
if (!userId) {

View File

@ -36,7 +36,8 @@ export class CheckoutController {
const cart = await this.checkoutService.buildCart(
body.orderType,
body.selections,
body.configuration
body.configuration,
req.user?.id
);
return checkoutBuildCartResponseSchema.parse({

View File

@ -1,187 +0,0 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { OrdersCacheService } from "../services/orders-cache.service";
type PubSubCallback = (
subscription: { topicName?: string },
callbackType: string,
data: unknown
) => void | Promise<void>;
interface PubSubClient {
connect(): Promise<void>;
subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise<void>;
close(): Promise<void>;
}
type PubSubCtor = new (opts: {
authType: string;
accessToken: string;
instanceUrl: string;
pubSubEndpoint: string;
}) => PubSubClient;
@Injectable()
export class OrderEventSubscriber implements OnModuleInit, OnModuleDestroy {
private client: PubSubClient | null = null;
private pubSubCtor: PubSubCtor | null = null;
private channel: string | null = null;
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
private readonly ordersCache: OrdersCacheService,
@Inject(Logger) private readonly logger: Logger
) {}
async onModuleInit(): Promise<void> {
const channel = this.config.get<string>("SF_ORDER_EVENT_CHANNEL");
if (!channel || channel.trim().length === 0) {
this.logger.debug("Salesforce order event subscription disabled", { channel });
return;
}
this.channel = channel.trim();
try {
const client = await this.ensureClient();
await client.subscribe(this.channel, this.handleOrderEvent.bind(this));
this.logger.log("Subscribed to Salesforce order change events", {
channel: this.channel,
});
} catch (error) {
this.logger.warn("Failed to subscribe to Salesforce order events", {
channel: this.channel,
error: error instanceof Error ? error.message : String(error),
});
}
}
async onModuleDestroy(): Promise<void> {
if (!this.client) return;
try {
await this.client.close();
this.logger.debug("Closed Salesforce order event subscriber", {
channel: this.channel,
});
} catch (error) {
this.logger.warn("Failed to close Salesforce order event subscriber cleanly", {
error: error instanceof Error ? error.message : String(error),
});
}
}
private async ensureClient(): Promise<PubSubClient> {
if (this.client) {
return this.client;
}
const ctor = await this.loadPubSubCtor();
await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken();
const instanceUrl = this.sfConnection.getInstanceUrl();
if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token or instance URL missing for order subscriber");
}
const pubSubEndpoint = this.config.get<string>(
"SF_PUBSUB_ENDPOINT",
"api.pubsub.salesforce.com:7443"
);
const client = new ctor({
authType: "OAuth",
accessToken,
instanceUrl,
pubSubEndpoint,
});
await client.connect();
this.client = client;
return client;
}
private async loadPubSubCtor(): Promise<PubSubCtor> {
if (!this.pubSubCtor) {
const ctor = (PubSubApiClientPkg as { default?: PubSubCtor }).default;
if (!ctor) {
throw new Error("Failed to load Salesforce Pub/Sub client constructor");
}
this.pubSubCtor = ctor;
}
return this.pubSubCtor;
}
private async handleOrderEvent(
subscription: { topicName?: string },
callbackType: string,
data: unknown
): Promise<void> {
const normalizedType = String(callbackType || "").toLowerCase();
if (normalizedType !== "data" && normalizedType !== "event") {
return;
}
const topic = subscription.topicName || this.channel || "unknown";
const payload = this.extractPayload(data);
const orderId = this.extractStringField(payload, ["OrderId__c", "OrderId", "Id"]);
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId"]);
if (!orderId) {
this.logger.warn("Received order event without OrderId; ignoring", { topic, payload });
return;
}
try {
await this.ordersCache.invalidateOrder(orderId);
if (accountId) {
await this.ordersCache.invalidateAccountOrders(accountId);
}
this.logger.log("Invalidated order cache via Salesforce event", {
topic,
orderId,
accountId,
});
} catch (error) {
this.logger.warn("Failed to invalidate order cache from Salesforce event", {
topic,
orderId,
accountId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private extractPayload(data: unknown): Record<string, unknown> | undefined {
if (!data || typeof data !== "object") {
return undefined;
}
const candidate = data as { payload?: unknown };
if (candidate.payload && typeof candidate.payload === "object") {
return candidate.payload as Record<string, unknown>;
}
return data as Record<string, unknown>;
}
private extractStringField(
payload: Record<string, unknown> | undefined,
fieldNames: string[]
): string | undefined {
if (!payload) return undefined;
for (const field of fieldNames) {
const value = payload[field];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
}

View File

@ -8,6 +8,7 @@ import {
Sse,
UsePipes,
UseGuards,
UnauthorizedException,
type MessageEvent,
} from "@nestjs/common";
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
@ -79,7 +80,10 @@ export class OrdersController {
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
@UseGuards(SalesforceReadThrottleGuard)
async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParam) {
return this.orderOrchestrator.getOrder(params.sfOrderId);
if (!req.user?.id) {
throw new UnauthorizedException("Authentication required");
}
return this.orderOrchestrator.getOrderForUser(params.sfOrderId, req.user.id);
}
@Sse(":sfOrderId/events")

View File

@ -28,7 +28,6 @@ import { SimFulfillmentService } from "./services/sim-fulfillment.service";
import { ProvisioningQueueService } from "./queue/provisioning.queue";
import { ProvisioningProcessor } from "./queue/provisioning.processor";
import { OrderFieldConfigModule } from "./config/order-field-config.module";
import { OrderEventSubscriber } from "./events/order-events.subscriber";
@Module({
imports: [
@ -64,7 +63,6 @@ import { OrderEventSubscriber } from "./events/order-events.subscriber";
// Async provisioning queue
ProvisioningQueueService,
ProvisioningProcessor,
OrderEventSubscriber,
],
exports: [
OrderOrchestrator,

View File

@ -0,0 +1,129 @@
/// <reference types="jest" />
import { BadRequestException } from "@nestjs/common";
import type { Logger } from "nestjs-pino";
import { CheckoutService } from "./checkout.service";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import type { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service";
import type { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service";
import type { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service";
const createLogger = (): Logger =>
({
log: jest.fn(),
error: jest.fn(),
}) as unknown as Logger;
const internetPlan = {
id: "prod-1",
sku: "PLAN-1",
name: "Plan 1",
description: "Plan 1",
monthlyPrice: 1000,
oneTimePrice: 0,
} as unknown;
const createService = ({
internet,
sim,
vpn,
}: {
internet: Partial<InternetCatalogService>;
sim: Partial<SimCatalogService>;
vpn: Partial<VpnCatalogService>;
}) =>
new CheckoutService(
createLogger(),
internet as InternetCatalogService,
sim as SimCatalogService,
vpn as VpnCatalogService
);
describe("CheckoutService - personalized carts", () => {
it("uses personalized internet plans when userId is provided", async () => {
const internetCatalogService = {
getPlansForUser: jest.fn().mockResolvedValue([internetPlan]),
getPlans: jest.fn(),
getInstallations: jest.fn().mockResolvedValue([]),
getAddons: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: {
getPlans: jest.fn(),
getPlansForUser: jest.fn(),
getAddons: jest.fn(),
getActivationFees: jest.fn(),
},
vpn: {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
},
});
await service.buildCart(
ORDER_TYPE.INTERNET,
{ planSku: "PLAN-1" },
undefined,
"user-123"
);
expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123");
expect(internetCatalogService.getPlans).not.toHaveBeenCalled();
});
it("rejects plans that are not available to the user", async () => {
const internetCatalogService = {
getPlansForUser: jest.fn().mockResolvedValue([]),
getPlans: jest.fn(),
getInstallations: jest.fn().mockResolvedValue([]),
getAddons: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: {
getPlans: jest.fn(),
getPlansForUser: jest.fn(),
getAddons: jest.fn(),
getActivationFees: jest.fn(),
},
vpn: {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
},
});
await expect(
service.buildCart(ORDER_TYPE.INTERNET, { planSku: "UNKNOWN" }, undefined, "user-123")
).rejects.toThrow(BadRequestException);
});
it("falls back to shared catalog when userId is not provided", async () => {
const internetCatalogService = {
getPlansForUser: jest.fn(),
getPlans: jest.fn().mockResolvedValue([internetPlan]),
getInstallations: jest.fn().mockResolvedValue([]),
getAddons: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: {
getPlans: jest.fn(),
getPlansForUser: jest.fn(),
getAddons: jest.fn(),
getActivationFees: jest.fn(),
},
vpn: {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
},
});
await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" });
expect(internetCatalogService.getPlans).toHaveBeenCalledTimes(1);
expect(internetCatalogService.getPlansForUser).not.toHaveBeenCalled();
});
});

View File

@ -39,7 +39,8 @@ export class CheckoutService {
async buildCart(
orderType: OrderTypeValue,
selections: OrderSelections,
configuration?: OrderConfigurations
configuration?: OrderConfigurations,
userId?: string
): Promise<CheckoutCart> {
this.logger.log("Building checkout cart", { orderType, selections });
@ -48,11 +49,11 @@ export class CheckoutService {
let totals: CheckoutTotals = { monthlyTotal: 0, oneTimeTotal: 0 };
if (orderType === ORDER_TYPE.INTERNET) {
const cart = await this.buildInternetCart(selections);
const cart = await this.buildInternetCart(selections, userId);
items.push(...cart.items);
totals = this.calculateTotals(items);
} else if (orderType === ORDER_TYPE.SIM) {
const cart = await this.buildSimCart(selections);
const cart = await this.buildSimCart(selections, userId);
items.push(...cart.items);
totals = this.calculateTotals(items);
} else if (orderType === ORDER_TYPE.VPN) {
@ -142,9 +143,14 @@ export class CheckoutService {
/**
* Build Internet order cart
*/
private async buildInternetCart(selections: OrderSelections): Promise<{ items: CheckoutItem[] }> {
private async buildInternetCart(
selections: OrderSelections,
userId?: string
): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = [];
const plans: InternetPlanCatalogItem[] = await this.internetCatalogService.getPlans();
const plans: InternetPlanCatalogItem[] = userId
? await this.internetCatalogService.getPlansForUser(userId)
: await this.internetCatalogService.getPlans();
const addons: InternetAddonCatalogItem[] = await this.internetCatalogService.getAddons();
const installations: InternetInstallationCatalogItem[] =
await this.internetCatalogService.getInstallations();
@ -211,9 +217,14 @@ export class CheckoutService {
/**
* Build SIM order cart
*/
private async buildSimCart(selections: OrderSelections): Promise<{ items: CheckoutItem[] }> {
private async buildSimCart(
selections: OrderSelections,
userId?: string
): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = [];
const plans: SimCatalogProduct[] = await this.simCatalogService.getPlans();
const plans: SimCatalogProduct[] = userId
? await this.simCatalogService.getPlansForUser(userId)
: await this.simCatalogService.getPlans();
const activationFees: SimActivationFeeCatalogItem[] =
await this.simCatalogService.getActivationFees();
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();

View File

@ -149,8 +149,7 @@ export class OrderFulfillmentOrchestrator {
// Step 3: Execute the main fulfillment workflow as a distributed transaction
let mappingResult: WhmcsOrderItemMappingResult | undefined;
let whmcsCreateResult: { orderId: number } | undefined;
let whmcsAcceptResult: WhmcsOrderResult | undefined;
let whmcsCreateResult: WhmcsOrderResult | undefined;
const fulfillmentResult =
await this.distributedTransactionService.executeDistributedTransaction(
@ -272,23 +271,18 @@ export class OrderFulfillmentOrchestrator {
});
}
const result = await this.whmcsOrderService.acceptOrder(
whmcsCreateResult.orderId,
sfOrderId
);
whmcsAcceptResult = result;
return result;
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId };
},
rollback: () => {
if (whmcsAcceptResult?.orderId) {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API for accepted orders
// Manual intervention required for service termination
this.logger.error(
"WHMCS order accepted but fulfillment failed - manual cleanup required",
{
orderId: whmcsAcceptResult.orderId,
serviceIds: whmcsAcceptResult.serviceIds,
orderId: whmcsCreateResult.orderId,
serviceIds: whmcsCreateResult.serviceIds,
sfOrderId,
action: "MANUAL_SERVICE_TERMINATION_REQUIRED",
}
@ -322,7 +316,7 @@ export class OrderFulfillmentOrchestrator {
Id: sfOrderId,
Status: "Completed",
Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsAcceptResult?.orderId?.toString(),
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
@ -332,8 +326,8 @@ export class OrderFulfillmentOrchestrator {
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
whmcsOrderId: whmcsAcceptResult?.orderId,
whmcsServiceIds: whmcsAcceptResult?.serviceIds,
whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds,
},
});
return result;
@ -374,7 +368,7 @@ export class OrderFulfillmentOrchestrator {
// Update context with results
context.mappingResult = mappingResult;
context.whmcsResult = whmcsAcceptResult;
context.whmcsResult = whmcsCreateResult;
this.logger.log("Transactional fulfillment completed successfully", {
sfOrderId,

View File

@ -0,0 +1,105 @@
/// <reference types="jest" />
import { NotFoundException } from "@nestjs/common";
import type { Logger } from "nestjs-pino";
import { OrderOrchestrator } from "./order-orchestrator.service";
import type { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service";
import type { OrderValidator } from "./order-validator.service";
import type { OrderBuilder } from "./order-builder.service";
import type { OrderItemBuilder } from "./order-item-builder.service";
import type { OrdersCacheService } from "./orders-cache.service";
import type { OrderDetails } from "@customer-portal/domain/orders";
const buildLogger = (): Logger =>
({
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}) as unknown as Logger;
const createOrderDetails = (overrides: Partial<OrderDetails> = {}): OrderDetails => ({
id: "006000000000000AAA",
orderNumber: "O-123",
status: "Open",
effectiveDate: new Date().toISOString(),
totalAmount: 100,
createdDate: new Date().toISOString(),
lastModifiedDate: new Date().toISOString(),
activationStatus: "Pending",
itemsSummary: [],
items: [],
accountId: "001000000000000AAA",
...overrides,
});
describe("OrderOrchestrator.getOrderForUser", () => {
const logger = buildLogger();
const salesforce = {} as SalesforceOrderService;
const orderValidator = {
validateUserMapping: jest.fn(),
} as unknown as OrderValidator;
const orderBuilder = {} as OrderBuilder;
const orderItemBuilder = {} as OrderItemBuilder;
const ordersCache = {} as OrdersCacheService;
const buildOrchestrator = () =>
new OrderOrchestrator(
logger,
salesforce,
orderValidator,
orderBuilder,
orderItemBuilder,
ordersCache
);
beforeEach(() => {
jest.resetAllMocks();
});
it("returns the order when the Salesforce account matches the user mapping", async () => {
const orchestrator = buildOrchestrator();
const expectedOrder = createOrderDetails();
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
userId: "user-1",
sfAccountId: expectedOrder.accountId,
whmcsClientId: 42,
});
jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1");
expect(result).toBe(expectedOrder);
expect(orchestrator.getOrder).toHaveBeenCalledWith(expectedOrder.id);
});
it("throws NotFound when the user mapping lacks a Salesforce account", async () => {
const orchestrator = buildOrchestrator();
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
userId: "user-1",
whmcsClientId: 42,
});
const getOrderSpy = jest.spyOn(orchestrator, "getOrder");
await expect(orchestrator.getOrderForUser("006000000000000AAA", "user-1")).rejects.toThrow(
NotFoundException
);
expect(getOrderSpy).not.toHaveBeenCalled();
});
it("throws NotFound when the order belongs to a different account", async () => {
const orchestrator = buildOrchestrator();
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
userId: "user-1",
sfAccountId: "001000000000000AAA",
whmcsClientId: 42,
});
jest
.spyOn(orchestrator, "getOrder")
.mockResolvedValue(createOrderDetails({ accountId: "001000000000999ZZZ" }));
await expect(orchestrator.getOrderForUser("006000000000000AAA", "user-1")).rejects.toThrow(
NotFoundException
);
});
});

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service";
import { OrderValidator } from "./order-validator.service";
@ -105,6 +105,42 @@ export class OrderOrchestrator {
);
}
/**
* Get order scoped to authenticated user account
*/
async getOrderForUser(orderId: string, userId: string): Promise<OrderDetailsResponse> {
const userMapping = await this.orderValidator.validateUserMapping(userId);
const sfAccountId = userMapping.sfAccountId
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId")
: null;
if (!sfAccountId) {
this.logger.warn({ userId }, "User mapping missing Salesforce account ID");
throw new NotFoundException("Order not found");
}
const safeOrderId = assertSalesforceId(orderId, "orderId");
const order = await this.getOrder(safeOrderId);
if (!order) {
throw new NotFoundException("Order not found");
}
if (!order.accountId || order.accountId !== sfAccountId) {
this.logger.warn(
{
userId,
orderId: safeOrderId,
orderAccountId: order.accountId,
expectedAccountId: sfAccountId,
},
"Order access denied due to account mismatch"
);
throw new NotFoundException("Order not found");
}
return order;
}
/**
* Get orders for a user with basic item summary
*/

View File

@ -41,7 +41,7 @@ describe("Catalog contract", () => {
throw new Error("Expected Nest application to expose an HTTP server");
}
const response = await request(serverCandidate).get("/catalog/internet/plans");
const response = await request(serverCandidate).get("/api/catalog/internet/plans");
expect(response.status).toBe(200);
const payload = internetCatalogApiResponseSchema.parse(response.body);

View File

@ -89,7 +89,7 @@ export function useProfileData() {
setFormData(next);
return true;
} catch (err) {
logger.error(err, "Error updating profile");
logger.error("Error updating profile", err);
setError(err instanceof Error ? err.message : "Failed to update profile");
return false;
} finally {
@ -116,7 +116,7 @@ export function useProfileData() {
setAddress(next);
return true;
} catch (err) {
logger.error(err, "Error updating address");
logger.error("Error updating address", err);
setError(err instanceof Error ? err.message : "Failed to update address");
return false;
} finally {

View File

@ -31,7 +31,7 @@ export function SessionTimeoutWarning({
const expiryTime = Date.parse(session.accessExpiresAt);
if (Number.isNaN(expiryTime)) {
logger.warn({ expiresAt: session.accessExpiresAt }, "Invalid access token expiry");
logger.warn("Invalid access token expiry", { expiresAt: session.accessExpiresAt });
expiryRef.current = null;
setShowWarning(false);
setTimeLeft(0);
@ -145,7 +145,7 @@ export function SessionTimeoutWarning({
setShowWarning(false);
setTimeLeft(0);
} catch (error) {
logger.error(error, "Failed to extend session");
logger.error("Failed to extend session", error);
await logout({ reason: "session-expired" });
}
})();

View File

@ -87,7 +87,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
}
applyAuthResponse(parsed.data);
} catch (error) {
logger.error(error, "Failed to refresh session");
logger.error("Failed to refresh session", error);
const errorInfo = getErrorInfo(error);
const reason = logoutReasonFromErrorCode(errorInfo.code) ?? ("session-expired" as const);
await get().logout({ reason });
@ -111,12 +111,14 @@ export const useAuthStore = create<AuthState>()((set, get) => {
// Set up global listener for 401 errors from API client
if (typeof window !== "undefined") {
window.addEventListener("auth:unauthorized", (event) => {
const customEvent = event as CustomEvent;
logger.warn(
{ url: customEvent.detail?.url, status: customEvent.detail?.status },
"401 Unauthorized detected - triggering logout"
);
type AuthUnauthorizedDetail = { url?: string; status?: number };
window.addEventListener("auth:unauthorized", event => {
const customEvent = event as CustomEvent<AuthUnauthorizedDetail>;
const detail = customEvent.detail;
logger.warn("401 Unauthorized detected - triggering logout", {
url: detail?.url,
status: detail?.status,
});
void get().logout({ reason: "session-expired" });
});
}
@ -172,7 +174,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
try {
await apiClient.POST("/api/auth/logout", {});
} catch (error) {
logger.warn(error, "Logout API call failed");
logger.warn("Logout API call failed", { error });
} finally {
set({
user: null,
@ -336,7 +338,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
await ensureSingleRefresh();
await fetchProfile();
} catch (refreshError) {
logger.error(refreshError, "Failed to refresh session after auth error");
logger.error("Failed to refresh session after auth error", refreshError);
return;
}
}

View File

@ -82,7 +82,7 @@ export function InvoiceTable({
});
openSsoLink(ssoLink.url, { newTab: true });
} catch (err) {
logger.error(err, "Failed to create payment SSO link");
logger.error("Failed to create payment SSO link", err);
} finally {
setPaymentLoading(null);
}
@ -101,7 +101,7 @@ export function InvoiceTable({
});
openSsoLink(ssoLink.url, { newTab: false });
} catch (err) {
logger.error(err, "Failed to create download SSO link");
logger.error("Failed to create download SSO link", err);
} finally {
setDownloadLoading(null);
}

View File

@ -36,7 +36,7 @@ export function InvoiceDetailContainer() {
if (target === "download") openSsoLink(ssoLink.url, { newTab: false });
else openSsoLink(ssoLink.url, { newTab: true });
} catch (err) {
logger.error(err, "Failed to create SSO link");
logger.error("Failed to create SSO link", err);
} finally {
if (target === "download") setLoadingDownload(false);
else setLoadingPayment(false);

View File

@ -59,7 +59,7 @@ export function PaymentMethodsContainer() {
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
openSsoLink(ssoLink.url, { newTab: true });
} catch (err: unknown) {
logger.error(err, "Failed to open payment methods");
logger.error("Failed to open payment methods", err);
// Check if error looks like an API error with response
if (
isApiError(err) &&

View File

@ -34,7 +34,7 @@ export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPayme
</span>
</div>
<div className="mt-1 text-2xl font-bold text-gray-900">
{formatCurrency(invoice.amount)}
{formatCurrency(invoice.amount, { currency: invoice.currency })}
</div>
<div className="mt-1 text-xs text-gray-500">
Exact due date: {format(new Date(invoice.dueDate), "MMMM d, yyyy")}

View File

@ -45,7 +45,7 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
const connect = () => {
if (isCancelled) return;
logger.debug({ orderId, url }, "Connecting to order updates stream");
logger.debug("Connecting to order updates stream", { orderId, url });
const es = new EventSource(url, { withCredentials: true });
eventSource = es;
@ -61,12 +61,12 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
handlerRef.current?.(payload);
}
} catch (error) {
logger.warn({ orderId, error }, "Failed to parse order update event");
logger.warn("Failed to parse order update event", { orderId, error });
}
};
const handleError = (error: Event) => {
logger.warn({ orderId, error }, "Order updates stream disconnected");
logger.warn("Order updates stream disconnected", { orderId, error });
es.close();
eventSource = null;
@ -92,4 +92,3 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
};
}, [orderId]);
}

View File

@ -34,7 +34,7 @@ export function NewSupportCaseView() {
// Redirect to cases list with success message
router.push("/support/cases?created=true");
} catch (error) {
logger.error({ error }, "Error creating case");
logger.error("Error creating case", error);
} finally {
setIsSubmitting(false);
}

View File

@ -100,4 +100,7 @@ export const queryKeys = {
list: () => ["orders", "list"] as const,
detail: (id: string | number) => ["orders", "detail", String(id)] as const,
},
currency: {
default: () => ["currency", "default"] as const,
},
} as const;

View File

@ -71,6 +71,19 @@ const BASE_URL_ENV_KEYS: readonly EnvKey[] = [
const DEFAULT_BASE_URL = "http://localhost:4000";
const resolveSameOriginBase = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
const globalLocation = (globalThis as { location?: { origin?: string } } | undefined)?.location;
if (globalLocation?.origin) {
return globalLocation.origin;
}
return DEFAULT_BASE_URL;
};
const normalizeBaseUrl = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
@ -78,7 +91,7 @@ const normalizeBaseUrl = (value: string) => {
}
if (trimmed === "/") {
return trimmed;
return resolveSameOriginBase();
}
return trimmed.replace(/\/+$/, "");

View File

@ -1,38 +1,33 @@
"use client";
import { useState, useEffect } from "react";
import { currencyService, FALLBACK_CURRENCY } from "@/lib/services/currency.service";
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/api";
import { currencyService } from "@/lib/services/currency.service";
import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing";
export function useCurrency() {
const [defaultCurrency, setDefaultCurrency] = useState<WhmcsCurrency | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
data,
isLoading,
isError,
error,
} = useQuery<WhmcsCurrency>({
queryKey: queryKeys.currency.default(),
queryFn: () => currencyService.getDefaultCurrency(),
staleTime: 60 * 60 * 1000, // cache currency for 1 hour
gcTime: 2 * 60 * 60 * 1000,
retry: 2,
});
useEffect(() => {
const loadCurrency = async () => {
try {
setLoading(true);
setError(null);
const currency = await currencyService.getDefaultCurrency();
setDefaultCurrency(currency);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load currency");
// Fallback to JPY if API fails
setDefaultCurrency(FALLBACK_CURRENCY);
} finally {
setLoading(false);
}
};
void loadCurrency();
}, []);
const resolvedCurrency = data ?? null;
const currencyCode = resolvedCurrency?.code ?? FALLBACK_CURRENCY.code;
const currencySymbol = resolvedCurrency?.prefix ?? FALLBACK_CURRENCY.prefix;
return {
currency: defaultCurrency,
loading,
error,
currencyCode: defaultCurrency?.code || "JPY",
currencySymbol: defaultCurrency?.prefix || "¥",
currency: resolvedCurrency,
loading: isLoading,
error: isError ? (error instanceof Error ? error.message : "Failed to load currency") : null,
currencyCode,
currencySymbol,
};
}

View File

@ -1,24 +1,28 @@
"use client";
import { useCurrency } from "@/lib/hooks/useCurrency";
import { FALLBACK_CURRENCY } from "@customer-portal/domain/billing";
import { formatCurrency as baseFormatCurrency } from "@customer-portal/domain/toolkit";
export type FormatCurrencyOptions = {
currency?: string;
currencySymbol?: string;
locale?: string;
showSymbol?: boolean;
};
export function useFormatCurrency() {
const { currencyCode, currencySymbol, loading, error } = useCurrency();
const formatCurrency = (amount: number) => {
if (loading) {
// Show loading state or fallback
return "¥" + amount.toLocaleString();
}
const formatCurrency = (amount: number, options?: FormatCurrencyOptions) => {
const resolvedCurrency = options?.currency ?? currencyCode ?? FALLBACK_CURRENCY.code;
const resolvedSymbol = options?.currencySymbol ?? currencySymbol ?? FALLBACK_CURRENCY.prefix;
if (error) {
// Fallback to JPY if there's an error
return baseFormatCurrency(amount, "JPY", "¥");
}
// Use the currency from WHMCS API
return baseFormatCurrency(amount, currencyCode, currencySymbol);
return baseFormatCurrency(amount, resolvedCurrency, {
currencySymbol: resolvedSymbol,
locale: options?.locale,
showSymbol: options?.showSymbol,
});
};
return {

View File

@ -30,10 +30,10 @@ export function useLocalStorage<T>(
setStoredValue(parsed as T);
}
} catch (error) {
logger.warn(
{ key, error: error instanceof Error ? error.message : String(error) },
"Error reading localStorage key"
);
logger.warn("Error reading localStorage key", {
key,
error: error instanceof Error ? error.message : String(error),
});
}
}, [key, isClient]);
@ -52,10 +52,10 @@ export function useLocalStorage<T>(
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
logger.warn(
{ key, error: error instanceof Error ? error.message : String(error) },
"Error setting localStorage key"
);
logger.warn("Error setting localStorage key", {
key,
error: error instanceof Error ? error.message : String(error),
});
}
},
[key, storedValue, isClient]
@ -69,10 +69,10 @@ export function useLocalStorage<T>(
window.localStorage.removeItem(key);
}
} catch (error) {
logger.warn(
{ key, error: error instanceof Error ? error.message : String(error) },
"Error removing localStorage key"
);
logger.warn("Error removing localStorage key", {
key,
error: error instanceof Error ? error.message : String(error),
});
}
}, [key, initialValue, isClient]);

View File

@ -1,14 +1,7 @@
import { apiClient, getDataOrThrow } from "@/lib/api";
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing";
export const FALLBACK_CURRENCY: WhmcsCurrency = {
id: 1,
code: "JPY",
prefix: "¥",
suffix: "",
format: "1",
rate: "1.00000",
};
export { FALLBACK_CURRENCY };
export const currencyService = {
async getDefaultCurrency(): Promise<WhmcsCurrency> {

View File

@ -131,37 +131,37 @@ TIME: 10:00:05 - CDC events for status updates
### **Scenario: Multiple CDC Events**
```
Event 1: Status = "Approved"
Event 1: Activation_Status__c = "Activating"
→ Guard checks:
Status is "Approved" (trigger)
activationStatus is null (not provisioning)
✅ whmcsOrderId is null (not provisioned)
activationStatus === "Activating"
(Optional) Status = "Approved" (trigger)
✅ whmcsOrderId = null (not provisioned)
→ PROVISION ✅
Event 2: Activation_Status__c = "Activating"
Event 2: Activation_Status__c = "Activated"
→ Guard checks:
Status didn't change (not in changedFields)
activationStatus !== "Activating"
→ SKIP ✅
Event 3: Status = "Completed", Activation_Status__c = "Activated"
Event 3: Status = "Completed"
→ Guard checks:
❌ Status is "Completed" (not "Approved")
❌ Status is not "Approved"/"Reactivate"
→ SKIP ✅
```
### **Scenario: Re-approval After Cancellation**
```
Event 1: Status = "Approved"
Event 1: Activation_Status__c = "Activating"
→ Provisions order ✅
→ WHMCS_Order_ID__c = "12345"
Event 2: Status = "Cancelled"
→ No provisioning (not "Approved") ✅
→ No provisioning (activationStatus ≠ "Activating") ✅
Event 3: Status = "Approved" again
Event 3: Activation_Status__c = "Activating" (re-approval Flow runs)
→ Guard checks:
Status is "Approved"
activationStatus === "Activating"
❌ whmcsOrderId = "12345" (already provisioned)
→ SKIP ✅ (prevents duplicate)
```
@ -205,7 +205,8 @@ SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
```
Admin: Sets Order Status = "Approved"
Result: Automatically provisions via CDC ✅
Flow: Sets Activation_Status__c = "Activating" and clears previous errors
Result: CDC event triggers provisioning ✅
```
### **Manual Status Changes:**
@ -214,7 +215,7 @@ Result: Automatically provisions via CDC ✅
Admin: Changes Status field directly
- Draft → Skip
- Pending Review → Skip
- Approved → Provision ✅
- Approved → Flow sets Activation_Status__c = "Activating" → Provision ✅
- Completed → Skip (invalidate cache only)
- Cancelled → Skip
```
@ -223,6 +224,7 @@ Admin: Changes Status field directly
```
Admin: Sets Status = "Reactivate"
Flow: Sets Activation_Status__c = "Activating" again
Result: Provisions again (if not already provisioned) ✅
```
@ -230,7 +232,25 @@ Result: Provisions again (if not already provisioned) ✅
## 🚨 Important Guards in Place
### **1. Idempotency Guard**
### **1. Activation Guard**
```typescript
if (activationStatus !== "Activating") {
// Only fire when Salesforce explicitly sets Activating
return;
}
```
### **2. Status Guard (Optional)**
```typescript
if (status && !PROVISION_TRIGGER_STATUSES.has(status)) {
// If Status value is present but not Approved/Reactivate, skip
return;
}
```
### **3. Idempotency Guard**
```typescript
if (whmcsOrderId) {
@ -239,24 +259,6 @@ if (whmcsOrderId) {
}
```
### **2. Status Guard**
```typescript
if (!PROVISION_TRIGGER_STATUSES.has(newStatus)) {
// Only "Approved" or "Reactivate" trigger provisioning
return;
}
```
### **3. Activation Status Guard**
```typescript
if (activationStatus === "Activating" || activationStatus === "Activated") {
// Already in progress or done, don't trigger again
return;
}
```
---
## 📊 Comparison: Platform Event vs CDC
@ -264,7 +266,7 @@ if (activationStatus === "Activating" || activationStatus === "Activated") {
| Aspect | Platform Event (Before) | CDC Only (Now) |
|--------|------------------------|----------------|
| **Salesforce Setup** | Need Platform Event + Flow | Just enable CDC |
| **Trigger Point** | Flow publishes (explicit) | Status change (automatic) |
| **Trigger Point** | Flow publishes (explicit) | Activation_Status__c = "Activating" (CDC) |
| **Complexity** | Two mechanisms | One mechanism |
| **Idempotency** | Flow handles | Guards in Portal |
| **Custom Context** | Yes (IdemKey, CorrelationId) | No (inferred) |
@ -279,10 +281,10 @@ if (activationStatus === "Activating" || activationStatus === "Activated") {
```bash
# In Salesforce
1. Create Order (Status: "Draft")
2. Set Status = "Approved"
2. Set Status = "Approved" (Flow flips Activation_Status__c = "Activating")
# Expected in Portal logs:
✅ Order status changed to provision trigger via CDC
✅ Order activation moved to Activating via CDC
✅ Successfully enqueued provisioning job
✅ Provisioning job completed
```
@ -292,7 +294,7 @@ if (activationStatus === "Activating" || activationStatus === "Activated") {
```bash
# In Salesforce
1. Order already provisioned (WHMCS_Order_ID__c exists)
2. Set Status = "Approved" again
2. Flow sets Activation_Status__c = "Activating" (e.g., operator retries)
# Expected in Portal logs:
✅ Order already has WHMCS Order ID, skipping provisioning
@ -302,9 +304,9 @@ if (activationStatus === "Activating" || activationStatus === "Activated") {
```bash
# In Salesforce
1. Order Status = "Approved" → Provisions
1. Order Status = "Approved" → Flow sets Activation_Status__c = "Activating" → Provisions
2. Set Status = "Cancelled"
3. Set Status = "Approved" again
3. Set Status = "Approved" again (Flow sets Activation_Status__c = "Activating")
# Expected in Portal logs:
✅ First approval: Provisions
@ -333,7 +335,7 @@ If you need to go back to Platform Events:
- Flow that publishes Order_Fulfilment_Requested__e
2. **Remove provisioning from OrderCdcSubscriber**
- Comment out the `handleStatusChange()` call
- Comment out the `handleActivationStatusChange()` call
- Keep cache invalidation logic
3. **Use SalesforcePubSubSubscriber again**
@ -350,7 +352,7 @@ If you need to go back to Platform Events:
- One less mechanism to manage
✅ **Automatic Triggering**
- Any Status change to "Approved" provisions
- Any Flow that sets `Activation_Status__c = "Activating"` provisions
- No manual event publishing
- Less room for human error
@ -370,7 +372,7 @@ If you need to go back to Platform Events:
Your system now uses **CDC-only for order provisioning**:
1. ✅ **OrderCdcSubscriber** triggers provisioning when Status = "Approved"
1. ✅ **OrderCdcSubscriber** triggers provisioning when Salesforce sets `Activation_Status__c = "Activating"`
2. ✅ **Multiple guards** prevent duplicate provisioning
3. ✅ **Cache invalidation** still works with smart filtering
4. ✅ **Simpler Salesforce setup** - just enable CDC
@ -379,7 +381,7 @@ Your system now uses **CDC-only for order provisioning**:
**Next Steps:**
1. Enable CDC on Order object in Salesforce
2. Restart your application
3. Test by approving an order
3. Test by approving an order (confirm Flow sets `Activation_Status__c = "Activating"`)
4. Monitor logs for successful provisioning
**Your CDC-only implementation is complete and production-ready!** 🚀

View File

@ -19,7 +19,7 @@ import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning
export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
// ... (same client setup as before)
private readonly PROVISION_STATUSES = new Set(["Approved", "Reactivate"]);
private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
constructor(
private readonly config: ConfigService,
@ -41,8 +41,8 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}
// 1. CHECK FOR PROVISIONING TRIGGER
if (changedFields.has("Status")) {
await this.handleStatusChange(payload, orderId, changedFields);
if (changedFields.has("Activation_Status__c")) {
await this.handleActivationStatusChange(payload, orderId);
}
// 2. CACHE INVALIDATION (existing logic)
@ -69,33 +69,30 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
/**
* Handle Status field changes and trigger provisioning if needed
*/
private async handleStatusChange(
private async handleActivationStatusChange(
payload: Record<string, unknown>,
orderId: string,
changedFields: Set<string>
orderId: string
): Promise<void> {
const newStatus = this.extractStringField(payload, ["Status"]);
const activationStatus = this.extractStringField(payload, ["Activation_Status__c"]);
const status = this.extractStringField(payload, ["Status"]);
// Guard: Only provision for specific statuses
if (!newStatus || !this.PROVISION_STATUSES.has(newStatus)) {
this.logger.debug("Status changed but not a provision trigger", {
orderId,
newStatus,
});
return;
}
// Guard: Don't trigger if already provisioning/provisioned
if (activationStatus === "Activating" || activationStatus === "Activated") {
this.logger.debug("Order already provisioning/provisioned, skipping", {
if (activationStatus !== "Activating") {
this.logger.debug("Activation status changed but not to Activating", {
orderId,
activationStatus,
});
return;
}
// Guard: Check if WHMCS Order ID already exists (idempotency)
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
this.logger.debug("Activation set to Activating but order status isn't Approved/Reactivate", {
orderId,
activationStatus,
status,
});
return;
}
const whmcsOrderId = this.extractStringField(payload, ["WHMCS_Order_ID__c"]);
if (whmcsOrderId) {
this.logger.log("Order already has WHMCS Order ID, skipping provisioning", {
@ -105,26 +102,26 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return;
}
// Trigger provisioning
this.logger.log("Order status changed to provision trigger, enqueuing fulfillment", {
this.logger.log("Activation status moved to Activating, enqueuing fulfillment", {
orderId,
status: newStatus,
activationStatus,
status,
});
try {
await this.provisioningQueue.enqueue({
sfOrderId: orderId,
idempotencyKey: `cdc-status-change-${Date.now()}-${orderId}`,
idempotencyKey: `cdc-activation-${Date.now()}-${orderId}`,
correlationId: `cdc-${orderId}`,
});
this.logger.log("Successfully enqueued provisioning job from CDC", {
this.logger.log("Successfully enqueued provisioning job from activation change", {
orderId,
trigger: "Status change to " + newStatus,
activationStatus,
status,
});
} catch (error) {
this.logger.error("Failed to enqueue provisioning job from CDC", {
this.logger.error("Failed to enqueue provisioning job from activation change", {
orderId,
error: error instanceof Error ? error.message : String(error),
});
@ -157,11 +154,11 @@ Portal (OrderCdcSubscriber):
```
Salesforce:
- Order Status = "Approved"
- Order Status = "Approved" (Flow sets Activation_Status__c = "Activating")
Portal (OrderCdcSubscriber):
- Receives OrderChangeEvent
- Checks: Status changed to "Approved"?
- Checks: Activation_Status__c changed to "Activating"?
- Yes → Enqueues provisioning job
- Also → Invalidates cache (if customer-facing)
```
@ -179,7 +176,7 @@ Portal (OrderCdcSubscriber):
- Easier to understand
3. ✅ **Automatic**
- Any Status change to "Approved" triggers provisioning
- Any Flow that sets `Activation_Status__c = "Activating"` triggers provisioning
- No manual Flow maintenance
## Drawbacks of CDC-Only
@ -225,7 +222,7 @@ If you decide to switch to CDC-Only:
1. **Update OrderCdcSubscriber**
- Add `ProvisioningQueueService` dependency
- Add `handleStatusChange()` method
- Add `handleActivationStatusChange()` method
- Add guards for idempotency
2. **Remove SalesforcePubSubSubscriber** (optional)
@ -237,7 +234,7 @@ If you decide to switch to CDC-Only:
- Or disable it
4. **Test Thoroughly**
- Test: Status → "Approved" triggers provisioning
- Test: Activation_Status__c → "Activating" triggers provisioning
- Test: Already provisioned orders don't re-trigger
- Test: Cancelled orders don't trigger
- Test: Cache invalidation still works

View File

@ -175,7 +175,7 @@ OrderItems [
| Field | Source | Example | Notes |
| --------------------- | -------------------- | --------- | ---------------------------- |
| `WHMCS_Service_ID__c` | AcceptOrder response | "67890" | Individual service ID |
| `WHMCS_Service_ID__c` | AddOrder response (`serviceids`) | "67890" | Individual service ID |
| `Billing_Cycle__c` | Already set | "Monthly" | No change during fulfillment |
## 🔧 Data Transformation Rules

View File

@ -63,16 +63,8 @@ EXPOSE_VALIDATION_ERRORS=false
WHMCS_BASE_URL=https://accounts.asolutions.co.jp
WHMCS_API_IDENTIFIER=
WHMCS_API_SECRET=
# Optional API access key if your deployment uses it
WHMCS_API_ACCESS_KEY=
# Optional webhook security for WHMCS webhooks
WHMCS_WEBHOOK_SECRET=
# Optional elevated admin credentials for privileged actions (eg. AcceptOrder)
# Provide the admin username and MD5 hash of the admin password.
# When set, the backend will use these ONLY for the AcceptOrder action.
WHMCS_ADMIN_USERNAME=
WHMCS_ADMIN_PASSWORD_MD5=
# Salesforce Credentials
SF_LOGIN_URL=https://asolutions.my.salesforce.com
SF_CLIENT_ID=
@ -98,9 +90,8 @@ SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000
SF_EVENTS_ENABLED=true
SF_CATALOG_EVENT_CHANNEL=/event/Product_and_Pricebook_Change__e
SF_ACCOUNT_EVENT_CHANNEL=/event/Account_Internet_Eligibility_Update__e
SF_ORDER_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
SF_EVENTS_REPLAY=LATEST
SF_PUBSUB_NUM_REQUESTED=50
SF_PUBSUB_NUM_REQUESTED=25
SF_PUBSUB_QUEUE_MAX=100
SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443

View File

@ -1,9 +1,29 @@
/**
* Billing Domain - Constants
*
*
* Domain constants for billing validation and business rules.
*/
import type { WhmcsCurrency } from "./providers/whmcs/raw.types";
// ============================================================================
// Currency Defaults
// ============================================================================
/**
* Single fallback currency for both BFF and Portal when WHMCS currency data
* is unavailable. This ensures a single source of truth for default currency
* formatting behaviour.
*/
export const FALLBACK_CURRENCY: WhmcsCurrency = {
id: 1,
code: "JPY",
prefix: "¥",
suffix: "",
format: "1",
rate: "1.00000",
};
// ============================================================================
// Invoice Validation Constants
// ============================================================================
@ -82,4 +102,3 @@ export function sanitizePaginationPage(page: number): number {
export type ValidInvoiceStatus = (typeof VALID_INVOICE_STATUSES)[number];
export type ValidInvoiceListStatus = (typeof VALID_INVOICE_LIST_STATUSES)[number];

View File

@ -125,20 +125,10 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
quantities.push(item.quantity);
// Handle config options - WHMCS expects base64 encoded serialized arrays
if (item.configOptions && Object.keys(item.configOptions).length > 0) {
const serialized = serializeForWhmcs(item.configOptions);
configOptions.push(serialized);
} else {
configOptions.push(""); // Empty string for items without config options
}
configOptions.push(serializeForWhmcs(item.configOptions));
// Handle custom fields - WHMCS expects base64 encoded serialized arrays
if (item.customFields && Object.keys(item.customFields).length > 0) {
const serialized = serializeForWhmcs(item.customFields);
customFields.push(serialized);
} else {
customFields.push(""); // Empty string for items without custom fields
}
customFields.push(serializeForWhmcs(item.customFields));
});
const payload: WhmcsAddOrderPayload = {
@ -162,6 +152,9 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
if (params.noemail !== undefined) {
payload.noemail = params.noemail;
}
if (params.notes) {
payload.notes = params.notes;
}
if (configOptions.some(opt => opt !== "")) {
payload.configoptions = configOptions;
}
@ -176,9 +169,26 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
* Serialize object for WHMCS API
* WHMCS expects base64-encoded serialized data
*/
function serializeForWhmcs(data: Record<string, string>): string {
const jsonStr = JSON.stringify(data);
return Buffer.from(jsonStr).toString("base64");
function serializeForWhmcs(data?: Record<string, string>): string {
if (!data || Object.keys(data).length === 0) {
return "";
}
const entries = Object.entries(data).map(([key, value]) => {
const safeKey = key ?? "";
const safeValue = value ?? "";
return (
`s:${Buffer.byteLength(safeKey, "utf8")}:"${escapePhpString(safeKey)}";` +
`s:${Buffer.byteLength(safeValue, "utf8")}:"${escapePhpString(safeValue)}";`
);
});
const serialized = `a:${entries.length}:{${entries.join("")}}`;
return Buffer.from(serialized).toString("base64");
}
function escapePhpString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**

View File

@ -70,6 +70,7 @@ export const whmcsAddOrderPayloadSchema = z.object({
clientid: z.number().int().positive(),
paymentmethod: z.string().min(1),
promocode: z.string().optional(),
notes: z.string().optional(),
noinvoice: z.boolean().optional(),
noinvoiceemail: z.boolean().optional(),
noemail: z.boolean().optional(),
@ -86,10 +87,22 @@ export type WhmcsAddOrderPayload = z.infer<typeof whmcsAddOrderPayloadSchema>;
// WHMCS Order Result Schema
// ============================================================================
export const whmcsAddOrderResponseSchema = z.object({
orderid: z.union([z.string(), z.number()]),
invoiceid: z.union([z.string(), z.number()]).optional(),
serviceids: z.string().optional(),
addonids: z.string().optional(),
domainids: z.string().optional(),
});
export type WhmcsAddOrderResponse = z.infer<typeof whmcsAddOrderResponseSchema>;
export const whmcsOrderResultSchema = z.object({
orderId: z.number().int().positive(),
invoiceId: z.number().int().positive().optional(),
serviceIds: z.array(z.number().int().positive()).default([]),
addonIds: z.array(z.number().int().positive()).default([]),
domainIds: z.array(z.number().int().positive()).default([]),
});
export type WhmcsOrderResult = z.infer<typeof whmcsOrderResultSchema>;
@ -99,11 +112,11 @@ export type WhmcsOrderResult = z.infer<typeof whmcsOrderResultSchema>;
// ============================================================================
export const whmcsAcceptOrderResponseSchema = z.object({
result: z.string(),
orderid: z.number().int().positive(),
invoiceid: z.number().int().positive().optional(),
productids: z.string().optional(), // Comma-separated service IDs
orderid: z.union([z.string(), z.number()]).optional(),
invoiceid: z.union([z.string(), z.number()]).optional(),
serviceids: z.string().optional(),
addonids: z.string().optional(),
domainids: z.string().optional(),
});
export type WhmcsAcceptOrderResponse = z.infer<typeof whmcsAcceptOrderResponseSchema>;