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:
parent
cbaa878000
commit
d943d04754
@ -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`
|
||||
|
||||
@ -127,7 +127,8 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
"^@/(.*)$": "<rootDir>/$1",
|
||||
"^@bff/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"passWithNoTests": true
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -334,7 +334,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
||||
timeout: config.timeout,
|
||||
retryAttempts: config.retryAttempts,
|
||||
retryDelay: config.retryDelay,
|
||||
hasAccessKey: Boolean(this.configService.getAccessKey()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
129
apps/bff/src/modules/orders/services/checkout.service.spec.ts
Normal file
129
apps/bff/src/modules/orders/services/checkout.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) &&
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(/\/+$/, "");
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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!** 🚀
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
11
env/portal-backend.env.sample
vendored
11
env/portal-backend.env.sample
vendored
@ -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
|
||||
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user