diff --git a/README.md b/README.md index d199c8a5..75d7484f 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/apps/bff/package.json b/apps/bff/package.json index b6daea4f..7c84b437 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -127,7 +127,8 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "^@/(.*)$": "/$1" + "^@/(.*)$": "/$1", + "^@bff/(.*)$": "/$1" }, "passWithNoTests": true } diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index e3a1de4a..0c9dc2c9 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -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"), diff --git a/apps/bff/src/core/security/controllers/csrf.controller.spec.ts b/apps/bff/src/core/security/controllers/csrf.controller.spec.ts index 8d256a62..81ce98c5 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.spec.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.spec.ts @@ -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"; diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index a2ff7148..3a11e533 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -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 { 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("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 { + private loadPubSubCtor(): Promise { 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("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; } } - diff --git a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts index 8b29bb8b..159c17e0 100644 --- a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts @@ -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 { const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true"; @@ -99,22 +102,29 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy { this.config.get("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("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 { - 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 { + 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 { + 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("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 - ): { 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; } } - diff --git a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts index 296f6dfe..d1b80249 100644 --- a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts @@ -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("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 */ diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index bf14b641..be83812b 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -334,7 +334,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { timeout: config.timeout, retryAttempts: config.retryAttempts, retryDelay: config.retryDelay, - hasAccessKey: Boolean(this.configService.getAccessKey()), }; } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 1bfd3e95..5b39c3fb 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -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); } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts index 0e9e2100..f31a46c1 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts @@ -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 { - 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(); + } } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index 6375b468..8a541e3c 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -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 { 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; + 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 { + async acceptOrder(orderId: number, sfOrderId?: string): Promise { 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; } + + 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)); + } } diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 72e164c7..0bd40f1b 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -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" }; } diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index 8edb3e66..ab58ce6a 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -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 { const userId = req.user?.id; if (!userId) { diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index 2381694d..4be0d86f 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -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({ diff --git a/apps/bff/src/modules/orders/events/order-events.subscriber.ts b/apps/bff/src/modules/orders/events/order-events.subscriber.ts deleted file mode 100644 index f76039ba..00000000 --- a/apps/bff/src/modules/orders/events/order-events.subscriber.ts +++ /dev/null @@ -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; - -interface PubSubClient { - connect(): Promise; - subscribe(topic: string, cb: PubSubCallback, numRequested?: number): Promise; - close(): Promise; -} - -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 { - const channel = this.config.get("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 { - 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 { - 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( - "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 { - 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 { - 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 | 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; - } - - return data as Record; - } - - private extractStringField( - payload: Record | 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; - } -} - diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index a233c086..22728e8f 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -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") diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 39e63a6e..74597d65 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -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, diff --git a/apps/bff/src/modules/orders/services/checkout.service.spec.ts b/apps/bff/src/modules/orders/services/checkout.service.spec.ts new file mode 100644 index 00000000..13cb7036 --- /dev/null +++ b/apps/bff/src/modules/orders/services/checkout.service.spec.ts @@ -0,0 +1,129 @@ +/// +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; + sim: Partial; + vpn: Partial; +}) => + 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(); + }); +}); diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index 8c1e438e..a54f8203 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -39,7 +39,8 @@ export class CheckoutService { async buildCart( orderType: OrderTypeValue, selections: OrderSelections, - configuration?: OrderConfigurations + configuration?: OrderConfigurations, + userId?: string ): Promise { 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(); diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index a03eeca6..83cae60b 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -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, diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts new file mode 100644 index 00000000..b9214093 --- /dev/null +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts @@ -0,0 +1,105 @@ +/// +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 => ({ + 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 + ); + }); +}); diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 82cc09c6..518b536a 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -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 { + 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 */ diff --git a/apps/bff/test/catalog-contract.spec.ts b/apps/bff/test/catalog-contract.spec.ts index 58ada1f0..b6661fe9 100644 --- a/apps/bff/test/catalog-contract.spec.ts +++ b/apps/bff/test/catalog-contract.spec.ts @@ -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); diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index 964a5d16..c2f729cb 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -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 { diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 712fe157..6da995da 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -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" }); } })(); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index bba6d85a..3d928605 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -87,7 +87,7 @@ export const useAuthStore = create()((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()((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; + 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()((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()((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; } } diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index 92112c64..54fdae50 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -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); } diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index f867c3ef..959e9c24 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -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); diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 86518eb3..067855e9 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -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) && diff --git a/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx b/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx index e2425018..7cd1e6ef 100644 --- a/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx +++ b/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx @@ -34,7 +34,7 @@ export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPayme
- {formatCurrency(invoice.amount)} + {formatCurrency(invoice.amount, { currency: invoice.currency })}
Exact due date: {format(new Date(invoice.dueDate), "MMMM d, yyyy")} diff --git a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts index f5a9295a..877ff987 100644 --- a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts +++ b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts @@ -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]); } - diff --git a/apps/portal/src/features/support/views/NewSupportCaseView.tsx b/apps/portal/src/features/support/views/NewSupportCaseView.tsx index 7f87d703..e4189317 100644 --- a/apps/portal/src/features/support/views/NewSupportCaseView.tsx +++ b/apps/portal/src/features/support/views/NewSupportCaseView.tsx @@ -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); } diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 911ba003..82505b86 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -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; diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index 20da56b2..ee878a6a 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -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(/\/+$/, ""); diff --git a/apps/portal/src/lib/hooks/useCurrency.ts b/apps/portal/src/lib/hooks/useCurrency.ts index 92bfcbac..467d9615 100644 --- a/apps/portal/src/lib/hooks/useCurrency.ts +++ b/apps/portal/src/lib/hooks/useCurrency.ts @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { + data, + isLoading, + isError, + error, + } = useQuery({ + 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, }; } diff --git a/apps/portal/src/lib/hooks/useFormatCurrency.ts b/apps/portal/src/lib/hooks/useFormatCurrency.ts index dde4cd9f..2de5027c 100644 --- a/apps/portal/src/lib/hooks/useFormatCurrency.ts +++ b/apps/portal/src/lib/hooks/useFormatCurrency.ts @@ -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 { diff --git a/apps/portal/src/lib/hooks/useLocalStorage.ts b/apps/portal/src/lib/hooks/useLocalStorage.ts index 8241d04a..aa018045 100644 --- a/apps/portal/src/lib/hooks/useLocalStorage.ts +++ b/apps/portal/src/lib/hooks/useLocalStorage.ts @@ -30,10 +30,10 @@ export function useLocalStorage( 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( 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( 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]); diff --git a/apps/portal/src/lib/services/currency.service.ts b/apps/portal/src/lib/services/currency.service.ts index 3e8f9a7c..aa47c5b7 100644 --- a/apps/portal/src/lib/services/currency.service.ts +++ b/apps/portal/src/lib/services/currency.service.ts @@ -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 { diff --git a/docs/CDC_ONLY_ORDER_IMPLEMENTATION.md b/docs/CDC_ONLY_ORDER_IMPLEMENTATION.md index b6c7c54d..62455ebb 100644 --- a/docs/CDC_ONLY_ORDER_IMPLEMENTATION.md +++ b/docs/CDC_ONLY_ORDER_IMPLEMENTATION.md @@ -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!** 🚀 diff --git a/docs/CDC_ONLY_PROVISIONING_ALTERNATIVE.md b/docs/CDC_ONLY_PROVISIONING_ALTERNATIVE.md index c7b34506..8c76db3a 100644 --- a/docs/CDC_ONLY_PROVISIONING_ALTERNATIVE.md +++ b/docs/CDC_ONLY_PROVISIONING_ALTERNATIVE.md @@ -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, - orderId: string, - changedFields: Set + orderId: string ): Promise { - 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 diff --git a/docs/salesforce/SALESFORCE-WHMCS-MAPPING-REFERENCE.md b/docs/salesforce/SALESFORCE-WHMCS-MAPPING-REFERENCE.md index fcae381b..a39c924a 100644 --- a/docs/salesforce/SALESFORCE-WHMCS-MAPPING-REFERENCE.md +++ b/docs/salesforce/SALESFORCE-WHMCS-MAPPING-REFERENCE.md @@ -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 diff --git a/env/portal-backend.env.sample b/env/portal-backend.env.sample index 8d478bc3..761c1f70 100644 --- a/env/portal-backend.env.sample +++ b/env/portal-backend.env.sample @@ -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 diff --git a/packages/domain/billing/constants.ts b/packages/domain/billing/constants.ts index 8df83cf3..1205b51d 100644 --- a/packages/domain/billing/constants.ts +++ b/packages/domain/billing/constants.ts @@ -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]; - diff --git a/packages/domain/orders/providers/whmcs/mapper.ts b/packages/domain/orders/providers/whmcs/mapper.ts index ebbd5d8d..8784eafd 100644 --- a/packages/domain/orders/providers/whmcs/mapper.ts +++ b/packages/domain/orders/providers/whmcs/mapper.ts @@ -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 { - const jsonStr = JSON.stringify(data); - return Buffer.from(jsonStr).toString("base64"); +function serializeForWhmcs(data?: Record): string { + if (!data || Object.keys(data).length === 0) { + return ""; + } + + const entries = Object.entries(data).map(([key, value]) => { + const safeKey = key ?? ""; + const safeValue = value ?? ""; + return ( + `s:${Buffer.byteLength(safeKey, "utf8")}:"${escapePhpString(safeKey)}";` + + `s:${Buffer.byteLength(safeValue, "utf8")}:"${escapePhpString(safeValue)}";` + ); + }); + + const serialized = `a:${entries.length}:{${entries.join("")}}`; + return Buffer.from(serialized).toString("base64"); +} + +function escapePhpString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** diff --git a/packages/domain/orders/providers/whmcs/raw.types.ts b/packages/domain/orders/providers/whmcs/raw.types.ts index f2f0ad35..e7efeec6 100644 --- a/packages/domain/orders/providers/whmcs/raw.types.ts +++ b/packages/domain/orders/providers/whmcs/raw.types.ts @@ -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; // 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; + 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; @@ -99,11 +112,11 @@ export type WhmcsOrderResult = z.infer; // ============================================================================ 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; -