Refactor EditorConfig and improve code formatting across multiple files

- Replaced the existing .editorconfig file to establish consistent coding styles across editors.
- Cleaned up whitespace and improved formatting in various TypeScript files for better readability.
- Enhanced logging and error handling in Salesforce and WHMCS services to provide clearer insights during operations.
- Streamlined order processing and caching mechanisms, ensuring efficient handling of API requests and responses.
- Updated test cases to reflect changes in service methods and improve overall test coverage.
This commit is contained in:
barsa 2025-11-17 11:49:58 +09:00
parent b5533994c2
commit ff55639b2d
54 changed files with 645 additions and 811 deletions

View File

@ -1 +0,0 @@
config/.editorconfig

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# EditorConfig helps maintain consistent coding styles across editors
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@ -85,7 +85,7 @@ export const envSchema = z.object({
SF_PUBSUB_NUM_REQUESTED: z.string().default("25"), SF_PUBSUB_NUM_REQUESTED: z.string().default("25"),
SF_PUBSUB_QUEUE_MAX: z.string().default("100"), SF_PUBSUB_QUEUE_MAX: z.string().default("100"),
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"), SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
// CDC-specific channels (using /data/ prefix for Change Data Capture) // CDC-specific channels (using /data/ prefix for Change Data Capture)
SF_CATALOG_PRODUCT_CDC_CHANNEL: z.string().default("/data/Product2ChangeEvent"), SF_CATALOG_PRODUCT_CDC_CHANNEL: z.string().default("/data/Product2ChangeEvent"),
SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL: z.string().default("/data/PricebookEntryChangeEvent"), SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL: z.string().default("/data/PricebookEntryChangeEvent"),

View File

@ -740,7 +740,8 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
getDegradationState(): SalesforceDegradationSnapshot { getDegradationState(): SalesforceDegradationSnapshot {
this.clearDegradeWindowIfElapsed(); this.clearDegradeWindowIfElapsed();
const usagePercent = this.dailyApiLimit > 0 ? this.metrics.dailyApiUsage / this.dailyApiLimit : 0; const usagePercent =
this.dailyApiLimit > 0 ? this.metrics.dailyApiUsage / this.dailyApiLimit : 0;
return { return {
degraded: this.degradeState.until !== null, degraded: this.degradeState.until !== null,
reason: this.degradeState.reason, reason: this.degradeState.reason,

View File

@ -47,11 +47,7 @@ describe("CsrfController", () => {
controller.getCsrfToken(req, res); controller.getCsrfToken(req, res);
expect(csrfService.generateToken).toHaveBeenCalledWith( expect(csrfService.generateToken).toHaveBeenCalledWith(undefined, "session-456", "user-123");
undefined,
"session-456",
"user-123"
);
}); });
it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => { it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => {

View File

@ -171,10 +171,13 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const invalidated = await this.catalogCache.invalidateProducts(productIds); const invalidated = await this.catalogCache.invalidateProducts(productIds);
if (!invalidated) { if (!invalidated) {
this.logger.debug("No catalog cache entries were linked to product IDs; falling back to full invalidation", { this.logger.debug(
channel, "No catalog cache entries were linked to product IDs; falling back to full invalidation",
productIds, {
}); channel,
productIds,
}
);
await this.invalidateAllCatalogs(); await this.invalidateAllCatalogs();
} }
} }
@ -206,16 +209,17 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productId, productId,
}); });
const invalidated = await this.catalogCache.invalidateProducts( const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
productId ? [productId] : []
);
if (!invalidated) { if (!invalidated) {
this.logger.debug("No catalog cache entries mapped to product from pricebook event; performing full invalidation", { this.logger.debug(
channel, "No catalog cache entries mapped to product from pricebook event; performing full invalidation",
pricebookId, {
productId, channel,
}); pricebookId,
productId,
}
);
await this.invalidateAllCatalogs(); await this.invalidateAllCatalogs();
} }
} }

View File

@ -9,8 +9,8 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber";
@Module({ @Module({
imports: [ConfigModule, IntegrationsModule, OrdersModule, CatalogModule], imports: [ConfigModule, IntegrationsModule, OrdersModule, CatalogModule],
providers: [ providers: [
CatalogCdcSubscriber, // CDC for catalog cache invalidation CatalogCdcSubscriber, // CDC for catalog cache invalidation
OrderCdcSubscriber, // CDC for order cache invalidation OrderCdcSubscriber, // CDC for order cache invalidation
], ],
}) })
export class SalesforceEventsModule {} export class SalesforceEventsModule {}

View File

@ -68,15 +68,10 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
]); ]);
// Internal OrderItem fields - ignore these // Internal OrderItem fields - ignore these
private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set([ private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
"WHMCS_Service_ID__c",
]);
// Statuses that trigger provisioning // Statuses that trigger provisioning
private readonly PROVISION_TRIGGER_STATUSES = new Set([ private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
"Approved",
"Reactivate",
]);
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
@ -96,11 +91,9 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
const orderChannel = const orderChannel =
this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || "/data/OrderChangeEvent";
"/data/OrderChangeEvent";
const orderItemChannel = const orderItemChannel =
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || "/data/OrderItemChangeEvent";
"/data/OrderItemChangeEvent";
this.logger.log("Initializing Salesforce Order CDC subscriber", { this.logger.log("Initializing Salesforce Order CDC subscriber", {
orderChannel, orderChannel,
@ -148,7 +141,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return this.client; return this.client;
} }
const ctor = await this.loadPubSubCtor(); const ctor = this.loadPubSubCtor();
await this.sfConnection.connect(); await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken(); const accessToken = this.sfConnection.getAccessToken();
@ -202,7 +195,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
} }
private async loadPubSubCtor(): Promise<PubSubCtor> { private loadPubSubCtor(): PubSubCtor {
if (this.pubSubCtor) { if (this.pubSubCtor) {
return this.pubSubCtor; return this.pubSubCtor;
} }
@ -284,11 +277,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields); const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
if (!hasCustomerFacingChange) { if (!hasCustomerFacingChange) {
this.logger.debug("Order CDC event contains only internal field changes; skipping cache invalidation", { this.logger.debug(
channel, "Order CDC event contains only internal field changes; skipping cache invalidation",
orderId, {
changedFields: Array.from(changedFields), channel,
}); orderId,
changedFields: Array.from(changedFields),
}
);
return; return;
} }
@ -336,11 +332,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) { if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
this.logger.debug("Activation status set to Activating but order status is not a provisioning trigger", { this.logger.debug(
orderId, "Activation status set to Activating but order status is not a provisioning trigger",
activationStatus, {
status, orderId,
}); activationStatus,
status,
}
);
return; return;
} }
@ -441,7 +440,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
// Remove internal fields from changed fields // Remove internal fields from changed fields
const customerFacingChanges = Array.from(changedFields).filter( const customerFacingChanges = Array.from(changedFields).filter(
(field) => !this.INTERNAL_FIELDS.has(field) field => !this.INTERNAL_FIELDS.has(field)
); );
return customerFacingChanges.length > 0; return customerFacingChanges.length > 0;
@ -456,7 +455,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
const customerFacingChanges = Array.from(changedFields).filter( const customerFacingChanges = Array.from(changedFields).filter(
(field) => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field) field => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field)
); );
return customerFacingChanges.length > 0; return customerFacingChanges.length > 0;
@ -471,15 +470,16 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const header = this.extractChangeEventHeader(payload); const header = this.extractChangeEventHeader(payload);
const headerChangedFields = Array.isArray(header?.changedFields) const headerChangedFields = Array.isArray(header?.changedFields)
? (header?.changedFields as unknown[]) ? (header?.changedFields as unknown[]).filter(
.filter((field): field is string => typeof field === "string" && field.length > 0) (field): field is string => typeof field === "string" && field.length > 0
)
: []; : [];
// CDC provides changed fields in different formats depending on API version // CDC provides changed fields in different formats depending on API version
// Try to extract from common locations // Try to extract from common locations
const changedFieldsArray = const changedFieldsArray =
(payload.changedFields as string[] | undefined) || (payload.changedFields as string[] | undefined) ||
((payload.changeOrigin as { changedFields?: string[] })?.changedFields) || (payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
[]; [];
return new Set([ return new Set([
@ -523,14 +523,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return undefined; return undefined;
} }
private extractChangeEventHeader( private extractChangeEventHeader(payload: Record<string, unknown>):
payload: Record<string, unknown> | {
): { changedFields?: unknown;
changedFields?: unknown; recordIds?: unknown;
recordIds?: unknown; entityName?: unknown;
entityName?: unknown; changeType?: unknown;
changeType?: unknown; }
} | undefined { | undefined {
const header = payload["ChangeEventHeader"]; const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") { if (header && typeof header === "object") {
return header as { return header as {

View File

@ -37,4 +37,3 @@ export class SalesforceReadThrottleGuard implements CanActivate {
); );
} }
} }

View File

@ -37,4 +37,3 @@ export class SalesforceWriteThrottleGuard implements CanActivate {
); );
} }
} }

View File

@ -56,9 +56,7 @@ export class SalesforceAccountService {
} }
} }
async findWithDetailsByCustomerNumber( async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
customerNumber: string
): Promise<{
id: string; id: string;
Name?: string | null; Name?: string | null;
WH_Account__c?: string | null; WH_Account__c?: string | null;
@ -160,7 +158,7 @@ export class SalesforceAccountService {
this.logger.warn("Salesforce update method not available"); this.logger.warn("Salesforce update method not available");
return; return;
} }
await updateMethod(payload as Record<string, unknown> & { Id: string }); await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.debug("Updated Salesforce account portal fields", { this.logger.debug("Updated Salesforce account portal fields", {
accountId: validAccountId, accountId: validAccountId,

View File

@ -9,10 +9,24 @@ describe("SalesforceConnection", () => {
get: jest.fn(), get: jest.fn(),
} as unknown as ConfigService; } as unknown as ConfigService;
const requestQueue: Partial<SalesforceRequestQueueService> = { const execute = jest.fn<
execute: jest.fn().mockImplementation(async (fn) => fn()), Promise<unknown>,
executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()), [fn: () => Promise<unknown>, options?: Record<string, unknown>]
}; >(async (fn): Promise<unknown> => {
return await fn();
});
const executeHighPriority = jest.fn<
Promise<unknown>,
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
>(async (fn): Promise<unknown> => {
return await fn();
});
const requestQueue = {
execute,
executeHighPriority,
} as unknown as SalesforceRequestQueueService;
const logger = { const logger = {
debug: jest.fn(), debug: jest.fn(),
@ -21,7 +35,7 @@ describe("SalesforceConnection", () => {
log: jest.fn(), log: jest.fn(),
} as unknown as Logger; } as unknown as Logger;
const service = new SalesforceConnection(configService, requestQueue as SalesforceRequestQueueService, logger); const service = new SalesforceConnection(configService, requestQueue, logger);
// Override internal connection with simple stubs // Override internal connection with simple stubs
const queryMock = jest.fn().mockResolvedValue("query-result"); const queryMock = jest.fn().mockResolvedValue("query-result");
@ -31,14 +45,18 @@ describe("SalesforceConnection", () => {
create: jest.fn().mockResolvedValue({ id: "001" }), create: jest.fn().mockResolvedValue({ id: "001" }),
update: jest.fn().mockResolvedValue({}), update: jest.fn().mockResolvedValue({}),
}), }),
} as unknown as typeof service["connection"]; } as unknown as (typeof service)["connection"];
jest.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected").mockResolvedValue(); jest
.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected")
.mockResolvedValue();
return { return {
service, service,
requestQueue, requestQueue,
queryMock, queryMock,
execute,
executeHighPriority,
}; };
}; };
@ -47,12 +65,12 @@ describe("SalesforceConnection", () => {
}); });
it("routes standard queries through the request queue with derived priority metadata", async () => { it("routes standard queries through the request queue with derived priority metadata", async () => {
const { service, requestQueue, queryMock } = createService(); const { service, execute, queryMock } = createService();
await service.query("SELECT Id FROM Account WHERE Id = '001'"); await service.query("SELECT Id FROM Account WHERE Id = '001'");
expect(requestQueue.execute).toHaveBeenCalledTimes(1); expect(execute).toHaveBeenCalledTimes(1);
const [, options] = (requestQueue.execute as jest.Mock).mock.calls[0]; const [, options] = execute.mock.calls[0];
expect(options).toMatchObject({ expect(options).toMatchObject({
priority: 8, priority: 8,
isLongRunning: false, isLongRunning: false,
@ -62,13 +80,13 @@ describe("SalesforceConnection", () => {
}); });
it("routes SObject create operations through the high-priority queue", async () => { it("routes SObject create operations through the high-priority queue", async () => {
const { service, requestQueue } = createService(); const { service, executeHighPriority } = createService();
const sobject = service.sobject("Order"); const sobject = service.sobject("Order");
await sobject.create({ Name: "Test" }); await sobject.create({ Name: "Test" });
expect(requestQueue.executeHighPriority).toHaveBeenCalledTimes(1); expect(executeHighPriority).toHaveBeenCalledTimes(1);
const [, options] = (requestQueue.executeHighPriority as jest.Mock).mock.calls[0]; const [, options] = executeHighPriority.mock.calls[0];
expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" }); expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" });
}); });
}); });

View File

@ -246,49 +246,47 @@ export class SalesforceConnection {
const isLongRunning = this.isLongRunningQuery(soql); const isLongRunning = this.isLongRunningQuery(soql);
const label = options.label ?? this.deriveQueryLabel(soql); const label = options.label ?? this.deriveQueryLabel(soql);
try { return this.requestQueue.execute(
return await this.requestQueue.execute( async () => {
async () => { await this.ensureConnected();
await this.ensureConnected(); try {
try { return await this.connection.query(soql);
return await this.connection.query(soql); } catch (error: unknown) {
} catch (error: unknown) { if (this.isSessionExpiredError(error)) {
if (this.isSessionExpiredError(error)) { const reAuthStartTime = Date.now();
const reAuthStartTime = Date.now(); this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
this.logger.warn("Salesforce session expired, attempting to re-authenticate", { originalError: getErrorMessage(error),
originalError: getErrorMessage(error), tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null, tokenExpiresAt: this.tokenExpiresAt
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null, ? new Date(this.tokenExpiresAt).toISOString()
: null,
});
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying query after re-authentication", {
reAuthDuration,
}); });
try { return await this.connection.query(soql);
await this.connect(true); } catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
const reAuthDuration = Date.now() - reAuthStartTime; this.logger.error("Failed to re-authenticate or retry query", {
this.logger.debug("Retrying query after re-authentication", { originalError: getErrorMessage(error),
reAuthDuration, retryError: getErrorMessage(retryError),
}); reAuthDuration,
});
return await this.connection.query(soql); throw retryError;
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry query", {
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
} }
throw error;
} }
},
{ priority, isLongRunning, label } throw error;
); }
} catch (error: unknown) { },
throw error; { priority, isLongRunning, label }
} );
} }
private isSessionExpiredError(error: unknown): boolean { private isSessionExpiredError(error: unknown): boolean {
@ -314,90 +312,96 @@ export class SalesforceConnection {
// Return a wrapper that handles session expiration for SObject operations // Return a wrapper that handles session expiration for SObject operations
return { return {
create: async (data: object) => { create: async (data: object) => {
return this.requestQueue.executeHighPriority(async () => { return this.requestQueue.executeHighPriority(
try { async () => {
await this.ensureConnected(); try {
return await originalSObject.create(data); await this.ensureConnected();
} catch (error: unknown) { return await originalSObject.create(data);
if (this.isSessionExpiredError(error)) { } catch (error: unknown) {
const reAuthStartTime = Date.now(); if (this.isSessionExpiredError(error)) {
this.logger.warn( const reAuthStartTime = Date.now();
"Salesforce session expired during SObject create, attempting to re-authenticate", this.logger.warn(
{ "Salesforce session expired during SObject create, attempting to re-authenticate",
sobjectType: type, {
originalError: getErrorMessage(error), sobjectType: type,
originalError: getErrorMessage(error),
}
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject create after re-authentication", {
sobjectType: type,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.create(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject create", {
sobjectType: type,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
} }
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject create after re-authentication", {
sobjectType: type,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.create(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject create", {
sobjectType: type,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
} }
throw error;
} }
throw error; },
} { label: `salesforce:sobject:${type}:create` }
}, { label: `salesforce:sobject:${type}:create` }); );
}, },
update: async (data: object & { Id: string }) => { update: async (data: object & { Id: string }) => {
return this.requestQueue.executeHighPriority(async () => { return this.requestQueue.executeHighPriority(
try { async () => {
await this.ensureConnected(); try {
return await originalSObject.update(data); await this.ensureConnected();
} catch (error: unknown) { return await originalSObject.update(data);
if (this.isSessionExpiredError(error)) { } catch (error: unknown) {
const reAuthStartTime = Date.now(); if (this.isSessionExpiredError(error)) {
this.logger.warn( const reAuthStartTime = Date.now();
"Salesforce session expired during SObject update, attempting to re-authenticate", this.logger.warn(
{ "Salesforce session expired during SObject update, attempting to re-authenticate",
sobjectType: type, {
recordId: data.Id, sobjectType: type,
originalError: getErrorMessage(error), recordId: data.Id,
originalError: getErrorMessage(error),
}
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject update after re-authentication", {
sobjectType: type,
recordId: data.Id,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.update(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject update", {
sobjectType: type,
recordId: data.Id,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
} }
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject update after re-authentication", {
sobjectType: type,
recordId: data.Id,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.update(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject update", {
sobjectType: type,
recordId: data.Id,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
} }
throw error;
} }
throw error; },
} { label: `salesforce:sobject:${type}:update` }
}, { label: `salesforce:sobject:${type}:update` }); );
}, },
}; };
} }
@ -412,39 +416,42 @@ export class SalesforceConnection {
const path = this.buildCompositeTreePath(sobjectType, allOrNone); const path = this.buildCompositeTreePath(sobjectType, allOrNone);
const label = options.label ?? `salesforce:composite:${sobjectType}`; const label = options.label ?? `salesforce:composite:${sobjectType}`;
return this.requestQueue.execute(async () => { return this.requestQueue.execute(
await this.ensureConnected(); async () => {
await this.ensureConnected();
if (!body || typeof body !== "object") { if (!body || typeof body !== "object") {
throw new TypeError("Salesforce composite tree body must be an object"); throw new TypeError("Salesforce composite tree body must be an object");
}
const payload = body as Record<string, unknown> | Record<string, unknown>[];
try {
return (await this.connection.requestPost(path, payload)) as T;
} catch (error) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn("Salesforce session expired during composite tree request, retrying", {
sobjectType,
originalError: getErrorMessage(error),
});
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying composite tree request after re-authentication", {
sobjectType,
reAuthDuration,
});
return (await this.connection.requestPost(path, payload)) as T;
} }
throw error; const payload = body as Record<string, unknown> | Record<string, unknown>[];
}
}, { priority, label }); try {
return (await this.connection.requestPost(path, payload)) as T;
} catch (error) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn("Salesforce session expired during composite tree request, retrying", {
sobjectType,
originalError: getErrorMessage(error),
});
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying composite tree request after re-authentication", {
sobjectType,
reAuthDuration,
});
return (await this.connection.requestPost(path, payload)) as T;
}
throw error;
}
},
{ priority, label }
);
} }
private buildCompositeTreePath(sobjectType: string, allOrNone: boolean): string { private buildCompositeTreePath(sobjectType: string, allOrNone: boolean): string {
@ -513,31 +520,34 @@ export class SalesforceConnection {
*/ */
async queryHighPriority(soql: string, options: { label?: string } = {}): Promise<unknown> { async queryHighPriority(soql: string, options: { label?: string } = {}): Promise<unknown> {
const label = options.label ?? `${this.deriveQueryLabel(soql)}:high`; const label = options.label ?? `${this.deriveQueryLabel(soql)}:high`;
return this.requestQueue.executeHighPriority(async () => { return this.requestQueue.executeHighPriority(
await this.ensureConnected(); async () => {
try { await this.ensureConnected();
return await this.connection.query(soql); try {
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during high-priority query, attempting to re-authenticate",
{
originalError: getErrorMessage(error),
}
);
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying high-priority query after re-authentication", {
reAuthDuration,
});
return await this.connection.query(soql); return await this.connection.query(soql);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during high-priority query, attempting to re-authenticate",
{
originalError: getErrorMessage(error),
}
);
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying high-priority query after re-authentication", {
reAuthDuration,
});
return await this.connection.query(soql);
}
throw error;
} }
throw error; },
} { label }
}, { label }); );
} }
/** /**

View File

@ -51,15 +51,13 @@ export class SalesforceOrderService {
// Build queries // Build queries
const orderQueryFields = this.orderFieldMap.buildOrderSelectFields(["Account.Name"]).join(", "); const orderQueryFields = this.orderFieldMap.buildOrderSelectFields(["Account.Name"]).join(", ");
const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map( const orderItemProduct2Fields = this.orderFieldMap
f => `PricebookEntry.Product2.${f}` .buildOrderItemProduct2Fields()
); .map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [ const orderItemSelect = [
...this.orderFieldMap.buildOrderItemSelectFields(), ...this.orderFieldMap.buildOrderItemSelectFields(),
...orderItemProduct2Fields, ...orderItemProduct2Fields,
].join( ].join(", ");
", "
);
const orderSoql = ` const orderSoql = `
SELECT ${orderQueryFields} SELECT ${orderQueryFields}
@ -222,15 +220,13 @@ export class SalesforceOrderService {
// Build queries // Build queries
const orderQueryFields = this.orderFieldMap.buildOrderSelectFields().join(", "); const orderQueryFields = this.orderFieldMap.buildOrderSelectFields().join(", ");
const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map( const orderItemProduct2Fields = this.orderFieldMap
f => `PricebookEntry.Product2.${f}` .buildOrderItemProduct2Fields()
); .map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [ const orderItemSelect = [
...this.orderFieldMap.buildOrderItemSelectFields(), ...this.orderFieldMap.buildOrderItemSelectFields(),
...orderItemProduct2Fields, ...orderItemProduct2Fields,
].join( ].join(", ");
", "
);
const ordersSoql = ` const ordersSoql = `
SELECT ${orderQueryFields} SELECT ${orderQueryFields}

View File

@ -133,7 +133,7 @@ export class WhmcsHttpClientService {
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
const requestBody = this.buildRequestBody(config, action, params, options); const requestBody = this.buildRequestBody(config, action, params);
const url = `${config.baseUrl}/includes/api.php`; const url = `${config.baseUrl}/includes/api.php`;
const response = await fetch(url, { const response = await fetch(url, {
@ -170,8 +170,7 @@ export class WhmcsHttpClientService {
private buildRequestBody( private buildRequestBody(
config: WhmcsApiConfig, config: WhmcsApiConfig,
action: string, action: string,
params: Record<string, unknown>, params: Record<string, unknown>
options: WhmcsRequestOptions
): string { ): string {
const formData = new URLSearchParams(); const formData = new URLSearchParams();
@ -221,11 +220,7 @@ export class WhmcsHttpClientService {
if (typeof value === "string") { if (typeof value === "string") {
return value; return value;
} }
if ( if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value); return String(value);
} }
if (value instanceof Date) { if (value instanceof Date) {
@ -273,14 +268,9 @@ export class WhmcsHttpClientService {
// Handle error responses according to WHMCS API documentation // Handle error responses according to WHMCS API documentation
if (parsedResponse.result === "error") { if (parsedResponse.result === "error") {
const errorMessage = this.toDisplayString( const { message, error, errorcode, ...additionalFields } = parsedResponse;
parsedResponse.message ?? parsedResponse.error, const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
"Unknown WHMCS API error" const errorCode = this.toDisplayString(errorcode, "unknown");
);
const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown");
// Extract all additional fields from the response for debugging
const { result, message, error, errorcode, ...additionalFields } = parsedResponse;
this.logger.error(`WHMCS API returned error [${action}]`, { this.logger.error(`WHMCS API returned error [${action}]`, {
errorMessage, errorMessage,
@ -291,10 +281,11 @@ export class WhmcsHttpClientService {
}); });
// Include full context in the error for better debugging // Include full context in the error for better debugging
const errorContext = Object.keys(additionalFields).length > 0 const errorContext =
? ` | Additional details: ${JSON.stringify(additionalFields)}` Object.keys(additionalFields).length > 0
: ''; ? ` | Additional details: ${JSON.stringify(additionalFields)}`
: "";
throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`); throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`);
} }

View File

@ -28,7 +28,7 @@ export class WhmcsOrderService {
/** /**
* Create order in WHMCS using AddOrder API * Create order in WHMCS using AddOrder API
* Maps Salesforce OrderItems to WHMCS products * Maps Salesforce OrderItems to WHMCS products
* *
* WHMCS API Response Structure: * WHMCS API Response Structure:
* Success: { orderid, productids, serviceids, addonids, domainids, invoiceid } * Success: { orderid, productids, serviceids, addonids, domainids, invoiceid }
* Error: Thrown by HTTP client before returning * Error: Thrown by HTTP client before returning
@ -49,7 +49,7 @@ export class WhmcsOrderService {
clientId: params.clientId, clientId: params.clientId,
productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0, productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0,
pids: addOrderPayload.pid, pids: addOrderPayload.pid,
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
billingCycles: addOrderPayload.billingcycle, billingCycles: addOrderPayload.billingcycle,
hasConfigOptions: Boolean(addOrderPayload.configoptions), hasConfigOptions: Boolean(addOrderPayload.configoptions),
hasCustomFields: Boolean(addOrderPayload.customfields), hasCustomFields: Boolean(addOrderPayload.customfields),
@ -60,9 +60,7 @@ export class WhmcsOrderService {
// Call WHMCS AddOrder API // Call WHMCS AddOrder API
// Note: The HTTP client throws errors automatically if result === "error" // Note: The HTTP client throws errors automatically if result === "error"
// So we only get here if the request was successful // So we only get here if the request was successful
const response = (await this.connection.addOrder( const response = (await this.connection.addOrder(addOrderPayload)) as WhmcsAddOrderResponse;
addOrderPayload
)) as WhmcsAddOrderResponse;
// Log the full response for debugging // Log the full response for debugging
this.logger.debug("WHMCS AddOrder response", { this.logger.debug("WHMCS AddOrder response", {
@ -115,7 +113,7 @@ export class WhmcsOrderService {
/** /**
* Accept/provision order in WHMCS using AcceptOrder API * Accept/provision order in WHMCS using AcceptOrder API
* This activates services and creates subscriptions * This activates services and creates subscriptions
* *
* WHMCS API Response Structure: * WHMCS API Response Structure:
* Success: { orderid, invoiceid, serviceids, addonids, domainids } * Success: { orderid, invoiceid, serviceids, addonids, domainids }
* Error: Thrown by HTTP client before returning * Error: Thrown by HTTP client before returning

View File

@ -1,8 +1,6 @@
export const PORTAL_STATUS_ACTIVE = "Active" as const; export const PORTAL_STATUS_ACTIVE = "Active" as const;
export const PORTAL_STATUS_NOT_YET = "Not Yet" as const; export const PORTAL_STATUS_NOT_YET = "Not Yet" as const;
export type PortalStatus = export type PortalStatus = typeof PORTAL_STATUS_ACTIVE | typeof PORTAL_STATUS_NOT_YET;
| typeof PORTAL_STATUS_ACTIVE
| typeof PORTAL_STATUS_NOT_YET;
export const PORTAL_SOURCE_NEW_SIGNUP = "New Signup" as const; export const PORTAL_SOURCE_NEW_SIGNUP = "New Signup" as const;
export const PORTAL_SOURCE_MIGRATED = "Migrated" as const; export const PORTAL_SOURCE_MIGRATED = "Migrated" as const;

View File

@ -506,7 +506,8 @@ export class SignupWorkflowService {
return unwrapped.value; return unwrapped.value;
} }
const resolved = await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized); const resolved =
await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized);
await this.cache.set( await this.cache.set(
cacheKey, cacheKey,
this.wrapAccountCacheEntry(resolved ?? null), this.wrapAccountCacheEntry(resolved ?? null),
@ -528,9 +529,10 @@ export class SignupWorkflowService {
return `${this.accountCachePrefix}${customerNumber}`; return `${this.accountCachePrefix}${customerNumber}`;
} }
private unwrapAccountCacheEntry( private unwrapAccountCacheEntry(cached: SignupAccountCacheEntry | null): {
cached: SignupAccountCacheEntry | null hit: boolean;
): { hit: boolean; value: SignupAccountSnapshot | null } { value: SignupAccountSnapshot | null;
} {
if (!cached) { if (!cached) {
return { hit: false, value: null }; return { hit: false, value: null };
} }
@ -542,9 +544,7 @@ export class SignupWorkflowService {
return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null }; return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null };
} }
private wrapAccountCacheEntry( private wrapAccountCacheEntry(snapshot: SignupAccountSnapshot | null): SignupAccountCacheEntry {
snapshot: SignupAccountSnapshot | null
): SignupAccountCacheEntry {
return { return {
value: snapshot ?? null, value: snapshot ?? null,
__signupCache: true, __signupCache: true,

View File

@ -107,7 +107,10 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor(private authFacade: AuthFacade, private readonly jwtService: JwtService) {} constructor(
private authFacade: AuthFacade,
private readonly jwtService: JwtService
) {}
private setAuthCookies(res: Response, tokens: AuthTokens): void { private setAuthCookies(res: Response, tokens: AuthTokens): void {
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt); const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
@ -211,7 +214,7 @@ export class AuthController {
if (payload?.sub) { if (payload?.sub) {
userId = payload.sub; userId = payload.sub;
} }
} catch (error) { } catch {
// Ignore verification errors we still want to clear client cookies. // Ignore verification errors we still want to clear client cookies.
} }
} }

View File

@ -27,10 +27,7 @@ export class FailedLoginThrottleGuard {
} }
} }
static applyRateLimitHeaders( static applyRateLimitHeaders(request: RequestWithRateLimit, response: Response): void {
request: RequestWithRateLimit,
response: Response
): void {
const outcome = request.__authRateLimit; const outcome = request.__authRateLimit;
if (!outcome) return; if (!outcome) return;

View File

@ -16,7 +16,8 @@ import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
type CookieValue = string | undefined; type CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & { type RequestBase = Omit<Request, "cookies" | "route">;
type RequestWithCookies = RequestBase & {
cookies?: Record<string, CookieValue>; cookies?: Record<string, CookieValue>;
}; };
type RequestWithRoute = RequestWithCookies & { type RequestWithRoute = RequestWithCookies & {

View File

@ -23,11 +23,6 @@ import { CatalogCacheService } from "./services/catalog-cache.service";
VpnCatalogService, VpnCatalogService,
CatalogCacheService, CatalogCacheService,
], ],
exports: [ exports: [InternetCatalogService, SimCatalogService, VpnCatalogService, CatalogCacheService],
InternetCatalogService,
SimCatalogService,
VpnCatalogService,
CatalogCacheService,
],
}) })
export class CatalogModule {} export class CatalogModule {}

View File

@ -41,7 +41,7 @@ export class BaseCatalogService {
): Promise<TRecord[]> { ): Promise<TRecord[]> {
try { try {
const res = (await this.sf.query(soql, { const res = (await this.sf.query(soql, {
label: `catalog:${context.replace(/\s+/g, "_" ).toLowerCase()}`, label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`,
})) as SalesforceResponse<TRecord>; })) as SalesforceResponse<TRecord>;
return res.records ?? []; return res.records ?? [];
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -35,10 +35,10 @@ export class CatalogCacheService {
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup // Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup
// Primary: CDC events invalidate cache when data changes (real-time) // Primary: CDC events invalidate cache when data changes (real-time)
// Backup: TTL expires unused cache entries (memory management) // Backup: TTL expires unused cache entries (memory management)
private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation
private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation
private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: CatalogCacheSnapshot = { private readonly metrics: CatalogCacheSnapshot = {
catalog: { hits: 0, misses: 0 }, catalog: { hits: 0, misses: 0 },
@ -197,7 +197,7 @@ export class CatalogCacheService {
// 3. No cache hit and no in-flight request - fetch fresh data // 3. No cache hit and no in-flight request - fetch fresh data
this.metrics[bucket].misses++; this.metrics[bucket].misses++;
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const fresh = await fetchFn(); const fresh = await fetchFn();
@ -209,22 +209,18 @@ export class CatalogCacheService {
if (unwrapped.dependencies) { if (unwrapped.dependencies) {
await this.unlinkDependenciesForKey(key, unwrapped.dependencies); await this.unlinkDependenciesForKey(key, unwrapped.dependencies);
} }
// Store in Redis for future requests // Store in Redis for future requests
if (ttlSeconds === null) { if (ttlSeconds === null) {
await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies)); await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies));
} else { } else {
await this.cache.set( await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies), ttlSeconds);
key,
this.wrapCachedValue(valueToStore, dependencies),
ttlSeconds
);
} }
if (dependencies) { if (dependencies) {
await this.linkDependencies(key, dependencies); await this.linkDependencies(key, dependencies);
} }
return fresh; return fresh;
} finally { } finally {
// Clean up: Remove from in-flight map when done (success or failure) // Clean up: Remove from in-flight map when done (success or failure)
@ -372,5 +368,7 @@ export class CatalogCacheService {
export interface CatalogCacheOptions<T> { export interface CatalogCacheOptions<T> {
allowNull?: boolean; allowNull?: boolean;
resolveDependencies?: (value: T) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined; resolveDependencies?: (
value: T
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
} }

View File

@ -101,9 +101,7 @@ export class InternetCatalogService extends BaseCatalogService {
}, },
{ {
resolveDependencies: installations => ({ resolveDependencies: installations => ({
productIds: installations productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)),
.map(item => item.id)
.filter((id): id is string => Boolean(id)),
}), }),
} }
); );

View File

@ -80,9 +80,7 @@ export class SimCatalogService extends BaseCatalogService {
}, },
{ {
resolveDependencies: products => ({ resolveDependencies: products => ({
productIds: products productIds: products.map(product => product.id).filter((id): id is string => Boolean(id)),
.map(product => product.id)
.filter((id): id is string => Boolean(id)),
}), }),
} }
); );
@ -119,9 +117,7 @@ export class SimCatalogService extends BaseCatalogService {
}, },
{ {
resolveDependencies: products => ({ resolveDependencies: products => ({
productIds: products productIds: products.map(product => product.id).filter((id): id is string => Boolean(id)),
.map(product => product.id)
.filter((id): id is string => Boolean(id)),
}), }),
} }
); );

View File

@ -64,11 +64,6 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module";
ProvisioningQueueService, ProvisioningQueueService,
ProvisioningProcessor, ProvisioningProcessor,
], ],
exports: [ exports: [OrderOrchestrator, CheckoutService, ProvisioningQueueService, OrdersCacheService],
OrderOrchestrator,
CheckoutService,
ProvisioningQueueService,
OrdersCacheService,
],
}) })
export class OrdersModule {} export class OrdersModule {}

View File

@ -61,12 +61,7 @@ describe("CheckoutService - personalized carts", () => {
}, },
}); });
await service.buildCart( await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" }, undefined, "user-123");
ORDER_TYPE.INTERNET,
{ planSku: "PLAN-1" },
undefined,
"user-123"
);
expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123"); expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123");
expect(internetCatalogService.getPlans).not.toHaveBeenCalled(); expect(internetCatalogService.getPlans).not.toHaveBeenCalled();

View File

@ -96,7 +96,11 @@ export class OrderBuilder {
assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber); assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber);
assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName); assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName);
assignIfString(orderFields, fieldNames.portingFirstName, config.portingFirstName); assignIfString(orderFields, fieldNames.portingFirstName, config.portingFirstName);
assignIfString(orderFields, fieldNames.portingLastNameKatakana, config.portingLastNameKatakana); assignIfString(
orderFields,
fieldNames.portingLastNameKatakana,
config.portingLastNameKatakana
);
assignIfString( assignIfString(
orderFields, orderFields,
fieldNames.portingFirstNameKatakana, fieldNames.portingFirstNameKatakana,

View File

@ -157,200 +157,203 @@ export class OrderFulfillmentOrchestrator {
const fulfillmentResult = const fulfillmentResult =
await this.distributedTransactionService.executeDistributedTransaction( await this.distributedTransactionService.executeDistributedTransaction(
[ [
{ {
id: "sf_status_update", id: "sf_status_update",
description: "Update Salesforce order status to Activating", description: "Update Salesforce order status to Activating",
execute: this.createTrackedStep(context, "sf_status_update", async () => { execute: this.createTrackedStep(context, "sf_status_update", async () => {
const result = await this.salesforceService.updateOrder({ const result = await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Activation_Status__c: "Activating", Activation_Status__c: "Activating",
}); });
this.orderEvents.publish(sfOrderId, { this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId, orderId: sfOrderId,
status: "Processing", status: "Processing",
activationStatus: "Activating", activationStatus: "Activating",
stage: "in_progress", stage: "in_progress",
source: "fulfillment", source: "fulfillment",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
return result; return result;
}), }),
rollback: async () => { rollback: async () => {
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Activation_Status__c: "Failed", Activation_Status__c: "Failed",
}); });
},
critical: true,
}, },
critical: true, {
}, id: "order_details",
{ description: "Retain order details in context",
id: "order_details", execute: this.createTrackedStep(context, "order_details", () =>
description: "Retain order details in context", Promise.resolve(context.orderDetails)
execute: this.createTrackedStep(context, "order_details", () => ),
Promise.resolve(context.orderDetails) critical: false,
), },
critical: false, {
}, id: "mapping",
{ description: "Map OrderItems to WHMCS format",
id: "mapping", execute: this.createTrackedStep(context, "mapping", () => {
description: "Map OrderItems to WHMCS format", if (!context.orderDetails) {
execute: this.createTrackedStep(context, "mapping", () => { return Promise.reject(new Error("Order details are required for mapping"));
if (!context.orderDetails) { }
return Promise.reject(new Error("Order details are required for mapping")); // Use domain mapper directly - single transformation!
} const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
// Use domain mapper directly - single transformation! mappingResult = result;
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
mappingResult = result;
this.logger.log("OrderItems mapped to WHMCS", { this.logger.log("OrderItems mapped to WHMCS", {
totalItems: result.summary.totalItems, totalItems: result.summary.totalItems,
serviceItems: result.summary.serviceItems, serviceItems: result.summary.serviceItems,
activationItems: result.summary.activationItems, activationItems: result.summary.activationItems,
});
return Promise.resolve(result);
}),
critical: true,
},
{
id: "whmcs_create",
description: "Create order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_create", async () => {
if (!context.validation) {
throw new OrderValidationException("Validation context is missing", {
sfOrderId,
step: "whmcs_create_order",
}); });
}
if (!mappingResult) {
throw new FulfillmentException("Mapping result is not available", {
sfOrderId,
step: "whmcs_create_order",
});
}
const orderNotes = OrderProviders.Whmcs.createOrderNotes( return Promise.resolve(result);
sfOrderId, }),
`Provisioned from Salesforce Order ${sfOrderId}` critical: true,
); },
{
const result = await this.whmcsOrderService.addOrder({ id: "whmcs_create",
clientId: context.validation.clientId, description: "Create order in WHMCS",
items: mappingResult.whmcsItems, execute: this.createTrackedStep(context, "whmcs_create", async () => {
paymentMethod: "stripe", if (!context.validation) {
promoCode: "1st Month Free (Monthly Plan)", throw new OrderValidationException("Validation context is missing", {
sfOrderId,
notes: orderNotes,
noinvoiceemail: true,
noemail: true,
});
whmcsCreateResult = result;
return result;
}),
rollback: () => {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API
// Manual intervention required for order cleanup
this.logger.error(
"WHMCS order created but fulfillment failed - manual cleanup required",
{
orderId: whmcsCreateResult.orderId,
sfOrderId, sfOrderId,
action: "MANUAL_CLEANUP_REQUIRED", step: "whmcs_create_order",
} });
); }
} if (!mappingResult) {
return Promise.resolve(); throw new FulfillmentException("Mapping result is not available", {
},
critical: true,
},
{
id: "whmcs_accept",
description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_accept", async () => {
if (!whmcsCreateResult?.orderId) {
throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", {
sfOrderId,
step: "whmcs_accept_order",
});
}
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId };
}),
rollback: () => {
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: whmcsCreateResult.orderId,
serviceIds: whmcsCreateResult.serviceIds,
sfOrderId, sfOrderId,
action: "MANUAL_SERVICE_TERMINATION_REQUIRED", step: "whmcs_create_order",
} });
}
const orderNotes = OrderProviders.Whmcs.createOrderNotes(
sfOrderId,
`Provisioned from Salesforce Order ${sfOrderId}`
); );
}
return Promise.resolve(); const result = await this.whmcsOrderService.addOrder({
}, clientId: context.validation.clientId,
critical: true, items: mappingResult.whmcsItems,
}, paymentMethod: "stripe",
{ promoCode: "1st Month Free (Monthly Plan)",
id: "sim_fulfillment", sfOrderId,
description: "SIM-specific fulfillment (if applicable)", notes: orderNotes,
execute: this.createTrackedStep(context, "sim_fulfillment", async () => { noinvoiceemail: true,
if (context.orderDetails?.orderType === "SIM") { noemail: true,
const configurations = this.extractConfigurations(payload.configurations);
await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails,
configurations,
}); });
return { completed: true as const };
} whmcsCreateResult = result;
return { skipped: true as const }; return result;
}), }),
critical: false, // SIM fulfillment failure shouldn't rollback the entire order rollback: () => {
}, if (whmcsCreateResult?.orderId) {
{ // Note: WHMCS doesn't have an automated cancel API
id: "sf_success_update", // Manual intervention required for order cleanup
description: "Update Salesforce with success", this.logger.error(
execute: this.createTrackedStep(context, "sf_success_update", async () => { "WHMCS order created but fulfillment failed - manual cleanup required",
const result = await this.salesforceService.updateOrder({ {
Id: sfOrderId, orderId: whmcsCreateResult.orderId,
Status: "Completed", sfOrderId,
Activation_Status__c: "Activated", action: "MANUAL_CLEANUP_REQUIRED",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(), }
}); );
this.orderEvents.publish(sfOrderId, { }
orderId: sfOrderId, return Promise.resolve();
status: "Completed", },
activationStatus: "Activated", critical: true,
stage: "completed",
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds,
},
});
return result;
}),
rollback: async () => {
await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
});
}, },
critical: true, {
}, id: "whmcs_accept",
], description: "Accept/provision order in WHMCS",
{ execute: this.createTrackedStep(context, "whmcs_accept", async () => {
description: `Order fulfillment for ${sfOrderId}`, if (!whmcsCreateResult?.orderId) {
timeout: 300000, // 5 minutes throw new WhmcsOperationException(
continueOnNonCriticalFailure: true, "WHMCS order ID missing before acceptance step",
} {
sfOrderId,
step: "whmcs_accept_order",
}
);
}
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId };
}),
rollback: () => {
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: whmcsCreateResult.orderId,
serviceIds: whmcsCreateResult.serviceIds,
sfOrderId,
action: "MANUAL_SERVICE_TERMINATION_REQUIRED",
}
);
}
return Promise.resolve();
},
critical: true,
},
{
id: "sim_fulfillment",
description: "SIM-specific fulfillment (if applicable)",
execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
if (context.orderDetails?.orderType === "SIM") {
const configurations = this.extractConfigurations(payload.configurations);
await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails,
configurations,
});
return { completed: true as const };
}
return { skipped: true as const };
}),
critical: false, // SIM fulfillment failure shouldn't rollback the entire order
},
{
id: "sf_success_update",
description: "Update Salesforce with success",
execute: this.createTrackedStep(context, "sf_success_update", async () => {
const result = await this.salesforceService.updateOrder({
Id: sfOrderId,
Status: "Completed",
Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
status: "Completed",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds,
},
});
return result;
}),
rollback: async () => {
await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
});
},
critical: true,
},
],
{
description: `Order fulfillment for ${sfOrderId}`,
timeout: 300000, // 5 minutes
continueOnNonCriticalFailure: true,
}
); );
if (!fulfillmentResult.success) { if (!fulfillmentResult.success) {

View File

@ -64,12 +64,12 @@ describe("OrderOrchestrator.getOrderForUser", () => {
sfAccountId: expectedOrder.accountId, sfAccountId: expectedOrder.accountId,
whmcsClientId: 42, whmcsClientId: 42,
}); });
jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder); const getOrderSpy = jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1"); const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1");
expect(result).toBe(expectedOrder); expect(result).toBe(expectedOrder);
expect(orchestrator.getOrder).toHaveBeenCalledWith(expectedOrder.id); expect(getOrderSpy).toHaveBeenCalledWith(expectedOrder.id);
}); });
it("throws NotFound when the user mapping lacks a Salesforce account", async () => { it("throws NotFound when the user mapping lacks a Salesforce account", async () => {

View File

@ -18,8 +18,8 @@ export class OrdersCacheService {
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup // Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup
// Primary: CDC events invalidate cache when customer-facing fields change // Primary: CDC events invalidate cache when customer-facing fields change
// Backup: TTL expires unused cache entries (memory management) // Backup: TTL expires unused cache entries (memory management)
private readonly SUMMARY_TTL_SECONDS = 3600; // 1 hour - order lists private readonly SUMMARY_TTL_SECONDS = 3600; // 1 hour - order lists
private readonly DETAIL_TTL_SECONDS = 7200; // 2 hours - individual orders private readonly DETAIL_TTL_SECONDS = 7200; // 2 hours - individual orders
private readonly metrics: OrdersCacheMetrics = { private readonly metrics: OrdersCacheMetrics = {
summaries: { hits: 0, misses: 0 }, summaries: { hits: 0, misses: 0 },
@ -109,19 +109,19 @@ export class OrdersCacheService {
// 3. No cache hit and no in-flight request - fetch fresh data // 3. No cache hit and no in-flight request - fetch fresh data
this.metrics[bucket].misses++; this.metrics[bucket].misses++;
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const fresh = await fetcher(); const fresh = await fetcher();
const valueToStore = allowNull ? (fresh ?? null) : fresh; const valueToStore = allowNull ? (fresh ?? null) : fresh;
// Store in Redis for future requests // Store in Redis for future requests
if (ttlSeconds === null) { if (ttlSeconds === null) {
await this.cache.set(key, this.wrapCachedValue(valueToStore)); await this.cache.set(key, this.wrapCachedValue(valueToStore));
} else { } else {
await this.cache.set(key, this.wrapCachedValue(valueToStore), ttlSeconds); await this.cache.set(key, this.wrapCachedValue(valueToStore), ttlSeconds);
} }
return fresh; return fresh;
} finally { } finally {
// Clean up: Remove from in-flight map when done (success or failure) // Clean up: Remove from in-flight map when done (success or failure)

View File

@ -120,4 +120,3 @@ export class UsersFacade {
return sanitized; return sanitized;
} }
} }

View File

@ -63,4 +63,3 @@ export class UserAuthRepository {
} }
} }
} }

View File

@ -124,7 +124,10 @@ export class UserProfileService {
return this.getProfile(validId); return this.getProfile(validId);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS"); this.logger.error(
{ userId: validId, error: msg },
"Failed to update customer profile in WHMCS"
);
if (msg.includes("WHMCS API Error")) { if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", "")); throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
@ -398,4 +401,3 @@ export class UserProfileService {
} }
} }
} }

View File

@ -16,10 +16,7 @@ const nextConfig = {
output: process.env.NODE_ENV === "production" ? "standalone" : undefined, output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
// Ensure workspace packages are transpiled correctly // Ensure workspace packages are transpiled correctly
transpilePackages: [ transpilePackages: ["@customer-portal/domain", "@customer-portal/validation"],
"@customer-portal/domain",
"@customer-portal/validation",
],
// Tell Next to NOT bundle these server-only libs // Tell Next to NOT bundle these server-only libs
serverExternalPackages: [ serverExternalPackages: [

View File

@ -22,7 +22,7 @@ export function usePaymentRefresh({
hasMethods, hasMethods,
}: UsePaymentRefreshOptions) { }: UsePaymentRefreshOptions) {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const hideToastTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const hideToastTimeout = useRef<number | null>(null);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false, visible: false,
text: "", text: "",

View File

@ -13,10 +13,7 @@ import {
deriveOrderStatusDescriptor, deriveOrderStatusDescriptor,
getServiceCategory, getServiceCategory,
} from "@/features/orders/utils/order-presenters"; } from "@/features/orders/utils/order-presenters";
import { import { buildOrderDisplayItems } from "@/features/orders/utils/order-display";
buildOrderDisplayItems,
summarizeOrderDisplayItems,
} from "@/features/orders/utils/order-display";
import type { OrderSummary } from "@customer-portal/domain/orders"; import type { OrderSummary } from "@customer-portal/domain/orders";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
@ -70,10 +67,10 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
() => buildOrderDisplayItems(order.itemsSummary), () => buildOrderDisplayItems(order.itemsSummary),
[order.itemsSummary] [order.itemsSummary]
); );
// Use just the order type as the service name // Use just the order type as the service name
const serviceName = order.orderType ? `${order.orderType} Service` : "Service Order"; const serviceName = order.orderType ? `${order.orderType} Service` : "Service Order";
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount); const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
const createdDate = useMemo(() => { const createdDate = useMemo(() => {
if (!order.createdDate) return null; if (!order.createdDate) return null;
@ -131,7 +128,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
<StatusPill label={statusDescriptor.label} variant={statusVariant} /> <StatusPill label={statusDescriptor.label} variant={statusVariant} />
</div> </div>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500"> <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span> <span className="font-medium">
#{order.orderNumber || String(order.id).slice(-8)}
</span>
<span></span> <span></span>
<span>{formattedCreatedDate || "—"}</span> <span>{formattedCreatedDate || "—"}</span>
</div> </div>
@ -149,13 +148,15 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)} )}
</div> </div>
</div> </div>
{/* Right section: Pricing */} {/* Right section: Pricing */}
{showPricing && ( {showPricing && (
<div className="flex items-start gap-4 flex-shrink-0"> <div className="flex items-start gap-4 flex-shrink-0">
{totals.monthlyTotal > 0 && ( {totals.monthlyTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p> <p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">
Monthly
</p>
<p className="text-xl font-bold text-gray-900"> <p className="text-xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()} ¥{totals.monthlyTotal.toLocaleString()}
</p> </p>
@ -163,7 +164,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)} )}
{totals.oneTimeTotal > 0 && ( {totals.oneTimeTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">One-Time</p> <p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">
One-Time
</p>
<p className="text-lg font-bold text-gray-900"> <p className="text-lg font-bold text-gray-900">
¥{totals.oneTimeTotal.toLocaleString()} ¥{totals.oneTimeTotal.toLocaleString()}
</p> </p>
@ -173,6 +176,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)} )}
</div> </div>
</div> </div>
{footer && <div className="border-t border-slate-100 bg-slate-50 px-6 py-3">{footer}</div>}
</article> </article>
); );
} }

View File

@ -24,7 +24,7 @@ export function OrderCardSkeleton() {
</div> </div>
</div> </div>
</div> </div>
{/* Right section: Pricing */} {/* Right section: Pricing */}
<div className="flex items-start gap-4 flex-shrink-0"> <div className="flex items-start gap-4 flex-shrink-0">
<div className="text-right space-y-1"> <div className="text-right space-y-1">
@ -39,4 +39,3 @@ export function OrderCardSkeleton() {
} }
export default OrderCardSkeleton; export default OrderCardSkeleton;

View File

@ -31,7 +31,10 @@ type GetOrderByIdOptions = {
signal?: AbortSignal; signal?: AbortSignal;
}; };
async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise<OrderDetails> { async function getOrderById(
orderId: string,
options: GetOrderByIdOptions = {}
): Promise<OrderDetails> {
const response = await apiClient.GET("/api/orders/{sfOrderId}", { const response = await apiClient.GET("/api/orders/{sfOrderId}", {
params: { path: { sfOrderId: orderId } }, params: { path: { sfOrderId: orderId } },
signal: options.signal, signal: options.signal,

View File

@ -1,7 +1,12 @@
import type { OrderItemSummary } from "@customer-portal/domain/orders"; import type { OrderItemSummary } from "@customer-portal/domain/orders";
import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters"; import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters";
export type OrderDisplayItemCategory = "service" | "installation" | "addon" | "activation" | "other"; export type OrderDisplayItemCategory =
| "service"
| "installation"
| "addon"
| "activation"
| "other";
export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other"; export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other";
@ -25,19 +30,6 @@ export interface OrderDisplayItem {
isBundle: boolean; isBundle: boolean;
} }
interface OrderItemGroup {
indices: number[];
items: OrderItemSummary[];
}
const CATEGORY_ORDER: OrderDisplayItemCategory[] = [
"service",
"installation",
"addon",
"activation",
"other",
];
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = { const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
monthly: 0, monthly: 0,
"one-time": 1, "one-time": 1,
@ -46,8 +38,7 @@ const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
const MONTHLY_SUFFIX = "/ month"; const MONTHLY_SUFFIX = "/ month";
const normalizeItemClass = (itemClass?: string | null): string => const normalizeItemClass = (itemClass?: string | null): string => (itemClass ?? "").toLowerCase();
(itemClass ?? "").toLowerCase();
const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => { const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => {
const normalizedClass = normalizeItemClass(item.itemClass); const normalizedClass = normalizeItemClass(item.itemClass);
@ -65,20 +56,13 @@ const coerceNumber = (value: unknown): number => {
return 0; return 0;
}; };
const buildOrderItemId = (group: OrderItemGroup, fallbackIndex: number): string => { const aggregateCharges = (items: OrderItemSummary[]): OrderDisplayItemCharge[] => {
const identifiers = group.items const accumulator = new Map<
.map(item => item.productId || item.sku || item.name) string,
.filter((id): id is string => typeof id === "string" && id.length > 0); OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }
if (identifiers.length === 0) { >();
return `order-item-${fallbackIndex}`;
}
return identifiers.join("|");
};
const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => { for (const item of items) {
const accumulator = new Map<string, OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }>();
for (const item of group.items) {
const amount = coerceNumber(item.totalPrice ?? item.unitPrice); const amount = coerceNumber(item.totalPrice ?? item.unitPrice);
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined); const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
@ -117,81 +101,9 @@ const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
}); });
}; };
const buildGroupName = (group: OrderItemGroup): string => {
// For bundles, combine the names
if (group.items.length > 1) {
return group.items
.map(item => item.productName || item.name || "Item")
.join(" + ");
}
const fallbackItem = group.items[0];
return fallbackItem?.productName || fallbackItem?.name || "Service item";
};
const determinePrimaryCategory = (group: OrderItemGroup): OrderDisplayItemCategory => {
const categories = group.items.map(resolveCategory);
for (const preferred of CATEGORY_ORDER) {
if (categories.includes(preferred)) {
return preferred;
}
}
return "other";
};
const collectCategories = (group: OrderItemGroup): OrderDisplayItemCategory[] => {
const unique = new Set<OrderDisplayItemCategory>();
group.items.forEach(item => unique.add(resolveCategory(item)));
return Array.from(unique);
};
const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean => const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean =>
charges.every(charge => charge.amount <= 0); charges.every(charge => charge.amount <= 0);
const buildOrderItemGroup = (items: OrderItemSummary[]): OrderItemGroup[] => {
const groups: OrderItemGroup[] = [];
const usedIndices = new Set<number>();
const productIndex = new Map<string, number[]>();
items.forEach((item, index) => {
const key = item.productId || item.sku;
if (!key) return;
const existing = productIndex.get(key);
if (existing) {
existing.push(index);
} else {
productIndex.set(key, [index]);
}
});
items.forEach((item, index) => {
if (usedIndices.has(index)) {
return;
}
if (item.isBundledAddon && item.bundledAddonId) {
const partnerCandidates = productIndex.get(item.bundledAddonId) ?? [];
const partnerIndex = partnerCandidates.find(candidate => candidate !== index && !usedIndices.has(candidate));
if (typeof partnerIndex === "number") {
const partner = items[partnerIndex];
if (partner) {
const orderedIndices = partnerIndex < index ? [partnerIndex, index] : [index, partnerIndex];
const groupItems = orderedIndices.map(i => items[i]);
groups.push({ indices: orderedIndices, items: groupItems });
usedIndices.add(index);
usedIndices.add(partnerIndex);
return;
}
}
}
groups.push({ indices: [index], items: [item] });
usedIndices.add(index);
});
return groups.sort((a, b) => a.indices[0] - b.indices[0]);
};
export function buildOrderDisplayItems( export function buildOrderDisplayItems(
items: OrderItemSummary[] | null | undefined items: OrderItemSummary[] | null | undefined
): OrderDisplayItem[] { ): OrderDisplayItem[] {
@ -201,9 +113,9 @@ export function buildOrderDisplayItems(
// Map items to display format - keep the order from the backend // Map items to display format - keep the order from the backend
return items.map((item, index) => { return items.map((item, index) => {
const charges = aggregateCharges({ indices: [index], items: [item] }); const charges = aggregateCharges([item]);
const isBundled = Boolean(item.isBundledAddon); const isBundled = Boolean(item.isBundledAddon);
return { return {
id: item.productId || item.sku || `order-item-${index}`, id: item.productId || item.sku || `order-item-${index}`,
name: item.productName || item.name || "Service item", name: item.productName || item.name || "Service item",
@ -219,10 +131,7 @@ export function buildOrderDisplayItems(
}); });
} }
export function summarizeOrderDisplayItems( export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
items: OrderDisplayItem[],
fallback: string
): string {
if (items.length === 0) { if (items.length === 0) {
return fallback; return fallback;
} }
@ -234,5 +143,3 @@ export function summarizeOrderDisplayItems(
return `${primary.name} +${rest.length} more`; return `${primary.name} +${rest.length} more`;
} }

View File

@ -22,7 +22,10 @@ import {
import { StatusPill } from "@/components/atoms/status-pill"; import { StatusPill } from "@/components/atoms/status-pill";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { ordersService } from "@/features/orders/services/orders.service"; import { ordersService } from "@/features/orders/services/orders.service";
import { useOrderUpdates, type OrderUpdateEventPayload } from "@/features/orders/hooks/useOrderUpdates"; import {
useOrderUpdates,
type OrderUpdateEventPayload,
} from "@/features/orders/hooks/useOrderUpdates";
import { import {
calculateOrderTotals, calculateOrderTotals,
deriveOrderStatusDescriptor, deriveOrderStatusDescriptor,
@ -209,8 +212,8 @@ export function OrderDetailContainer() {
} }
}, [data?.orderType, serviceCategory]); }, [data?.orderType, serviceCategory]);
const showFeeNotice = displayItems.some(item => const showFeeNotice = displayItems.some(
item.categories.includes("installation") || item.categories.includes("activation") item => item.categories.includes("installation") || item.categories.includes("activation")
); );
const fetchOrder = useCallback(async (): Promise<void> => { const fetchOrder = useCallback(async (): Promise<void> => {
@ -325,21 +328,28 @@ export function OrderDetailContainer() {
{/* Left: Title & Date */} {/* Left: Title & Date */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2> <div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
{statusDescriptor && ( {serviceIcon}
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} /> </div>
)} <div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2>
{statusDescriptor && (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
)}
</div>
{placedDate && <p className="text-sm text-gray-500">{placedDate}</p>}
</div>
</div> </div>
{placedDate && (
<p className="text-sm text-gray-500">{placedDate}</p>
)}
</div> </div>
{/* Right: Pricing Section */} {/* Right: Pricing Section */}
<div className="flex items-start gap-6 sm:gap-8"> <div className="flex items-start gap-6 sm:gap-8">
{totals.monthlyTotal > 0 && ( {totals.monthlyTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">Monthly</p> <p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
Monthly
</p>
<p className="text-3xl font-bold text-gray-900"> <p className="text-3xl font-bold text-gray-900">
{yenFormatter.format(totals.monthlyTotal)} {yenFormatter.format(totals.monthlyTotal)}
</p> </p>
@ -347,7 +357,9 @@ export function OrderDetailContainer() {
)} )}
{totals.oneTimeTotal > 0 && ( {totals.oneTimeTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">One-Time</p> <p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
One-Time
</p>
<p className="text-3xl font-bold text-gray-900"> <p className="text-3xl font-bold text-gray-900">
{yenFormatter.format(totals.oneTimeTotal)} {yenFormatter.format(totals.oneTimeTotal)}
</p> </p>
@ -361,7 +373,12 @@ export function OrderDetailContainer() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Order Items Section */} {/* Order Items Section */}
<div> <div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700" style={{ letterSpacing: '0.1em' }}>Order Details</h3> <h3
className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700"
style={{ letterSpacing: "0.1em" }}
>
Order Details
</h3>
<div className="space-y-4"> <div className="space-y-4">
{displayItems.length === 0 ? ( {displayItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500"> <div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500">
@ -369,7 +386,8 @@ export function OrderDetailContainer() {
</div> </div>
) : ( ) : (
displayItems.map((item, itemIndex) => { displayItems.map((item, itemIndex) => {
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other; const categoryConfig =
CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
const Icon = categoryConfig.icon; const Icon = categoryConfig.icon;
const style = getItemVisualStyle(item); const style = getItemVisualStyle(item);
@ -384,10 +402,12 @@ export function OrderDetailContainer() {
> >
{/* Icon + Title & Category | Price */} {/* Icon + Title & Category | Price */}
<div className="flex flex-1 items-start gap-3"> <div className="flex flex-1 items-start gap-3">
<div className={cn( <div
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg", className={cn(
style.icon "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg",
)}> style.icon
)}
>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<div className="flex flex-1 items-baseline justify-between gap-4 sm:items-center"> <div className="flex flex-1 items-baseline justify-between gap-4 sm:items-center">
@ -405,11 +425,16 @@ export function OrderDetailContainer() {
const descriptor = describeCharge(charge); const descriptor = describeCharge(charge);
if (charge.amount > 0) { if (charge.amount > 0) {
return ( return (
<div key={`${item.id}-charge-${index}`} className="whitespace-nowrap text-lg"> <div
key={`${item.id}-charge-${index}`}
className="whitespace-nowrap text-lg"
>
<span className="font-bold text-gray-900"> <span className="font-bold text-gray-900">
{yenFormatter.format(charge.amount)} {yenFormatter.format(charge.amount)}
</span> </span>
<span className="ml-2 text-xs font-medium text-gray-500">{descriptor}</span> <span className="ml-2 text-xs font-medium text-gray-500">
{descriptor}
</span>
</div> </div>
); );
} }
@ -441,9 +466,7 @@ export function OrderDetailContainer() {
<ClockIcon className="h-5 w-5 flex-shrink-0 text-blue-600" /> <ClockIcon className="h-5 w-5 flex-shrink-0 text-blue-600" />
<div> <div>
<p className="text-sm font-semibold text-blue-900">Next Steps</p> <p className="text-sm font-semibold text-blue-900">Next Steps</p>
<p className="mt-1 text-sm text-blue-800"> <p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
{statusDescriptor.nextAction}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -454,9 +477,14 @@ export function OrderDetailContainer() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" /> <ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" />
<div> <div>
<p className="text-sm font-semibold text-amber-900">Installation Fee Notice</p> <p className="text-sm font-semibold text-amber-900">
Installation Fee Notice
</p>
<p className="mt-1 text-sm text-amber-800"> <p className="mt-1 text-sm text-amber-800">
Standard installation is included. Additional charges may apply for weekend scheduling, express service, or specialized equipment installation. Any extra fees will be discussed and approved by you before work begins. Standard installation is included. Additional charges may apply for
weekend scheduling, express service, or specialized equipment
installation. Any extra fees will be discussed and approved by you before
work begins.
</p> </p>
</div> </div>
</div> </div>
@ -476,4 +504,3 @@ export function OrderDetailContainer() {
} }
export default OrderDetailContainer; export default OrderDetailContainer;

View File

@ -41,7 +41,7 @@ export function SimFeatureToggles({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const successTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
setVm(initial.vm); setVm(initial.vm);

View File

@ -76,10 +76,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
setError(err instanceof Error ? err.message : "Failed to load SIM information"); setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally { } finally {
if (controller.signal.aborted || !isMountedRef.current) { if (!controller.signal.aborted && isMountedRef.current) {
return; setLoading(false);
} }
setLoading(false);
} }
}, [subscriptionId]); }, [subscriptionId]);

View File

@ -22,18 +22,20 @@ import { logger } from "@/lib/logger";
async function handleApiError(response: Response): Promise<void> { async function handleApiError(response: Response): Promise<void> {
// Don't import useAuthStore at module level to avoid circular dependencies // Don't import useAuthStore at module level to avoid circular dependencies
// We'll handle auth errors by dispatching a custom event that the auth system can listen to // We'll handle auth errors by dispatching a custom event that the auth system can listen to
if (response.status === 401) { if (response.status === 401) {
logger.warn("Received 401 Unauthorized response - triggering logout"); logger.warn("Received 401 Unauthorized response - triggering logout");
// Dispatch a custom event that the auth system will listen to // Dispatch a custom event that the auth system will listen to
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:unauthorized", { window.dispatchEvent(
detail: { url: response.url, status: response.status } new CustomEvent("auth:unauthorized", {
})); detail: { url: response.url, status: response.status },
})
);
} }
} }
// Still throw the error so the calling code can handle it // Still throw the error so the calling code can handle it
let body: unknown; let body: unknown;
let message = response.statusText || `Request failed with status ${response.status}`; let message = response.statusText || `Request failed with status ${response.status}`;

View File

@ -6,12 +6,7 @@ import { currencyService } from "@/lib/services/currency.service";
import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing"; import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing";
export function useCurrency() { export function useCurrency() {
const { const { data, isLoading, isError, error } = useQuery<WhmcsCurrency>({
data,
isLoading,
isError,
error,
} = useQuery<WhmcsCurrency>({
queryKey: queryKeys.currency.default(), queryKey: queryKeys.currency.default(),
queryFn: () => currencyService.getDefaultCurrency(), queryFn: () => currencyService.getDefaultCurrency(),
staleTime: 60 * 60 * 1000, // cache currency for 1 hour staleTime: 60 * 60 * 1000, // cache currency for 1 hour

View File

@ -78,7 +78,6 @@ export const logger = new Logger();
export const log = { export const log = {
info: (message: string, meta?: LogMeta) => logger.info(message, meta), info: (message: string, meta?: LogMeta) => logger.info(message, meta),
warn: (message: string, meta?: LogMeta) => logger.warn(message, meta), warn: (message: string, meta?: LogMeta) => logger.warn(message, meta),
error: (message: string, error?: unknown, meta?: LogMeta) => error: (message: string, error?: unknown, meta?: LogMeta) => logger.error(message, error, meta),
logger.error(message, error, meta),
debug: (message: string, meta?: LogMeta) => logger.debug(message, meta), debug: (message: string, meta?: LogMeta) => logger.debug(message, meta),
}; };

View File

@ -1,3 +0,0 @@
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
export { ZodValidationExceptionFilter } from "./zod-exception.filter";
//# sourceMappingURL=index.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,wBAAwB,CAAC"}

View File

@ -1,10 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZodValidationExceptionFilter = exports.ZodValidationException = exports.createZodDto = exports.ZodValidationPipe = void 0;
var nestjs_zod_1 = require("nestjs-zod");
Object.defineProperty(exports, "ZodValidationPipe", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationPipe; } });
Object.defineProperty(exports, "createZodDto", { enumerable: true, get: function () { return nestjs_zod_1.createZodDto; } });
Object.defineProperty(exports, "ZodValidationException", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationException; } });
var zod_exception_filter_1 = require("./zod-exception.filter");
Object.defineProperty(exports, "ZodValidationExceptionFilter", { enumerable: true, get: function () { return zod_exception_filter_1.ZodValidationExceptionFilter; } });
//# sourceMappingURL=index.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,yCAAqF;AAA5E,+GAAA,iBAAiB,OAAA;AAAE,0GAAA,YAAY,OAAA;AAAE,oHAAA,sBAAsB,OAAA;AAChE,+DAAsE;AAA7D,oIAAA,4BAA4B,OAAA"}

View File

@ -1,11 +0,0 @@
import { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ZodValidationException } from "nestjs-zod";
export declare class ZodValidationExceptionFilter implements ExceptionFilter {
private readonly logger;
constructor(logger: Logger);
catch(exception: ZodValidationException, host: ArgumentsHost): void;
private isZodError;
private mapIssues;
}
//# sourceMappingURL=zod-exception.filter.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"zod-exception.filter.d.ts","sourceRoot":"","sources":["zod-exception.filter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAS,eAAe,EAAsB,MAAM,gBAAgB,CAAC;AAE3F,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AASpD,qBACa,4BAA6B,YAAW,eAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,MAAM;IAE3D,KAAK,CAAC,SAAS,EAAE,sBAAsB,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;IAsCnE,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,SAAS;CAOlB"}

View File

@ -1,75 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZodValidationExceptionFilter = void 0;
const common_1 = require("@nestjs/common");
const nestjs_pino_1 = require("nestjs-pino");
const nestjs_zod_1 = require("nestjs-zod");
let ZodValidationExceptionFilter = class ZodValidationExceptionFilter {
logger;
constructor(logger) {
this.logger = logger;
}
catch(exception, host) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const rawZodError = exception.getZodError();
let issues = [];
if (!this.isZodError(rawZodError)) {
this.logger.error("ZodValidationException did not contain a ZodError", {
path: request.url,
method: request.method,
providedType: typeof rawZodError,
});
}
else {
issues = this.mapIssues(rawZodError.issues);
}
this.logger.warn("Request validation failed", {
path: request.url,
method: request.method,
issues,
});
response.status(common_1.HttpStatus.BAD_REQUEST).json({
success: false,
error: {
code: "VALIDATION_FAILED",
message: "Request validation failed",
details: {
issues,
timestamp: new Date().toISOString(),
path: request.url,
},
},
});
}
isZodError(error) {
return Boolean(error && typeof error === "object" && Array.isArray(error.issues));
}
mapIssues(issues) {
return issues.map(issue => ({
path: issue.path.join(".") || "root",
message: issue.message,
code: issue.code,
}));
}
};
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter;
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter = __decorate([
(0, common_1.Catch)(nestjs_zod_1.ZodValidationException),
__param(0, (0, common_1.Inject)(nestjs_pino_1.Logger)),
__metadata("design:paramtypes", [nestjs_pino_1.Logger])
], ZodValidationExceptionFilter);
//# sourceMappingURL=zod-exception.filter.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"zod-exception.filter.js","sourceRoot":"","sources":["zod-exception.filter.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAA2F;AAE3F,6CAAqC;AACrC,2CAAoD;AAU7C,IAAM,4BAA4B,GAAlC,MAAM,4BAA4B;IACM;IAA7C,YAA6C,MAAc;QAAd,WAAM,GAAN,MAAM,CAAQ;IAAG,CAAC;IAE/D,KAAK,CAAC,SAAiC,EAAE,IAAmB;QAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QAChC,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,EAAY,CAAC;QAC7C,MAAM,OAAO,GAAG,GAAG,CAAC,UAAU,EAAW,CAAC;QAE1C,MAAM,WAAW,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QAC5C,IAAI,MAAM,GAAuB,EAAE,CAAC;QAEpC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YAClC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,mDAAmD,EAAE;gBACrE,IAAI,EAAE,OAAO,CAAC,GAAG;gBACjB,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,YAAY,EAAE,OAAO,WAAW;aACjC,CAAC,CAAC;QACL,CAAC;aAAM,CAAC;YACN,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,2BAA2B,EAAE;YAC5C,IAAI,EAAE,OAAO,CAAC,GAAG;YACjB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,MAAM;SACP,CAAC,CAAC;QAEH,QAAQ,CAAC,MAAM,CAAC,mBAAU,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC;YAC3C,OAAO,EAAE,KAAc;YACvB,KAAK,EAAE;gBACL,IAAI,EAAE,mBAAmB;gBACzB,OAAO,EAAE,2BAA2B;gBACpC,OAAO,EAAE;oBACP,MAAM;oBACN,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;oBACnC,IAAI,EAAE,OAAO,CAAC,GAAG;iBAClB;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAEO,UAAU,CAAC,KAAc;QAC/B,OAAO,OAAO,CACZ,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAE,KAA8B,CAAC,MAAM,CAAC,CAC5F,CAAC;IACJ,CAAC;IAEO,SAAS,CAAC,MAAkB;QAClC,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1B,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,MAAM;YACpC,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,IAAI,EAAE,KAAK,CAAC,IAAI;SACjB,CAAC,CAAC,CAAC;IACN,CAAC;CACF,CAAA;AAtDY,oEAA4B;uCAA5B,4BAA4B;IADxC,IAAA,cAAK,EAAC,mCAAsB,CAAC;IAEf,WAAA,IAAA,eAAM,EAAC,oBAAM,CAAC,CAAA;qCAA0B,oBAAM;GADhD,4BAA4B,CAsDxC"}