refactor: enterprise-grade cleanup of BFF and domain packages

Comprehensive refactoring across 70 files (net -298 lines) improving
type safety, error handling, and code organization:

- Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas
- Tighten Record<string, unknown> to bounded union types where possible
- Replace throw new Error with domain-specific exceptions (OrderException,
  FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.)
- Split AuthTokenService (625 lines) into TokenGeneratorService and
  TokenRefreshService with thin orchestrator
- Deduplicate FreebitClientService with shared makeRequest() method
- Add typed interfaces to WHMCS facade, order service, and fulfillment mapper
- Externalize hardcoded config values to ConfigService with env fallbacks
- Consolidate duplicate billing cycle enums into shared billingCycleSchema
- Standardize logger usage (nestjs-pino @Inject(Logger) everywhere)
- Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts
This commit is contained in:
barsa 2026-02-24 19:05:30 +09:00
parent f4918b8a79
commit b206de8dba
72 changed files with 1287 additions and 989 deletions

View File

@ -40,8 +40,3 @@ export const getDevAuthConfig = (): DevAuthConfig => {
skipOtp: isDevelopment && process.env["SKIP_OTP"] === "true",
};
};
/**
* @deprecated Use getDevAuthConfig() instead to ensure env vars are read after ConfigModule loads
*/
export const devAuthConfig = getDevAuthConfig();

View File

@ -7,7 +7,7 @@
* Uses Redis SET NX PX pattern for atomic lock acquisition with TTL.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
import { randomUUID } from "node:crypto";
@ -99,7 +99,7 @@ export class DistributedLockService {
const lock = await this.acquire(key, options);
if (!lock) {
throw new Error(`Unable to acquire lock for key: ${key}`);
throw new InternalServerErrorException(`Unable to acquire lock for key: ${key}`);
}
try {

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { TransactionService, type TransactionOperation } from "./transaction.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -331,7 +331,7 @@ export class DistributedTransactionService {
);
if (!externalResult.success) {
throw new Error(externalResult.error || "External operations failed");
throw new InternalServerErrorException(externalResult.error || "External operations failed");
}
this.logger.debug(`Executing database operations [${transactionId}]`);
@ -360,7 +360,7 @@ export class DistributedTransactionService {
});
if (!result.success) {
throw new Error(result.error || "Database transaction failed");
throw new InternalServerErrorException(result.error || "Database transaction failed");
}
return result.data!;
@ -503,7 +503,9 @@ export class DistributedTransactionService {
}
}
throw new Error(`Step ${step.id} failed after ${totalAttempts} attempts`);
throw new InternalServerErrorException(
`Step ${step.id} failed after ${totalAttempts} attempts`
);
}
private generateTransactionId(): string {

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue, Job } from "bullmq";
import { Logger } from "nestjs-pino";
@ -69,7 +69,7 @@ export class EmailQueueService {
error: errorMessage,
});
throw new Error(`Failed to queue email: ${errorMessage}`);
throw new InternalServerErrorException(`Failed to queue email: ${errorMessage}`);
}
}

View File

@ -24,17 +24,38 @@ export class FreebitClientService {
) {}
/**
* Make an authenticated request to Freebit API with retry logic
* Make an authenticated form-encoded request to Freebit API with retry logic
*/
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string,
payload: TPayload
): Promise<TResponse> {
return this.makeRequest<TResponse, TPayload>(endpoint, payload, "form");
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
return this.makeRequest<TResponse, TPayload>(endpoint, payload, "json");
}
/**
* Core authenticated request handler shared by form-encoded and JSON variants.
*/
private async makeRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string,
payload: TPayload,
contentType: "form" | "json"
): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint);
const requestPayload = { ...payload, authKey };
const logLabel = contentType === "json" ? "Freebit JSON API" : "Freebit API";
let attempt = 0;
try {
@ -42,7 +63,7 @@ export class FreebitClientService {
async () => {
attempt += 1;
this.logger.debug(`Freebit API request`, {
this.logger.debug(`${logLabel} request`, {
url,
attempt,
maxAttempts: config.retryAttempts,
@ -52,17 +73,27 @@ export class FreebitClientService {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const headers =
contentType === "json"
? { "Content-Type": "application/json" }
: { "Content-Type": "application/x-www-form-urlencoded" };
const body =
contentType === "json"
? JSON.stringify(requestPayload)
: `json=${JSON.stringify(requestPayload)}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${JSON.stringify(requestPayload)}`,
headers,
body,
signal: controller.signal,
});
if (!response.ok) {
const isProd = process.env["NODE_ENV"] === "production";
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
this.logger.error("Freebit API HTTP error", {
this.logger.error(`${logLabel} HTTP error`, {
url,
status: response.status,
statusText: response.statusText,
@ -87,140 +118,12 @@ export class FreebitClientService {
resultCode,
statusCode,
statusMessage: responseData.status?.message,
...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }),
};
this.logger.error("Freebit API returned error response", errorDetails);
// Also log to console for visibility in dev
if (!isProd) {
console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2));
}
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit API request successful", { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
}
},
{
maxAttempts: config.retryAttempts,
baseDelayMs: 1000,
maxDelayMs: 10000,
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", {
url,
});
this.authService.clearAuthCache();
return true;
}
return error.isRetryable();
}
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit API request",
}
);
// Track successful API call
this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => {
this.logger.debug("Failed to track API call", {
error: error instanceof Error ? error.message : String(error),
});
});
return responseData;
} catch (error: unknown) {
// Track failed API call
this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => {
this.logger.debug("Failed to track API call error", {
error: trackError instanceof Error ? trackError.message : String(trackError),
});
});
throw error;
}
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint);
// Add authKey to the payload for authentication
const requestPayload = { ...payload, authKey };
let attempt = 0;
// Log request details in dev for debugging
const isProd = process.env["NODE_ENV"] === "production";
if (!isProd) {
this.logger.debug("[FREEBIT JSON API REQUEST]", {
url,
payload: redactForLogs(requestPayload),
});
}
try {
const responseData = await withRetry(
async () => {
attempt += 1;
this.logger.debug("Freebit JSON API request", {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(requestPayload),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestPayload),
signal: controller.signal,
});
if (!response.ok) {
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
);
}
const responseData = (await response.json()) as TResponse;
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
const isProd = process.env["NODE_ENV"] === "production";
const errorDetails = {
url,
resultCode,
statusCode,
message: responseData.status?.message,
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
attempt,
};
this.logger.error("Freebit JSON API returned error result code", errorDetails);
// Always log to console in dev for visibility
if (!isProd) {
console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2));
}
this.logger.error(`${logLabel} returned error response`, errorDetails);
this.logger.debug({ errorDetails }, `${logLabel} error details`);
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
@ -229,7 +132,7 @@ export class FreebitClientService {
);
}
this.logger.debug("Freebit JSON API request successful", { url, resultCode });
this.logger.debug(`${logLabel} request successful`, { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
@ -242,7 +145,7 @@ export class FreebitClientService {
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", {
this.logger.warn(`${logLabel} auth error detected, clearing cache and retrying`, {
url,
});
this.authService.clearAuthCache();
@ -253,7 +156,7 @@ export class FreebitClientService {
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit JSON API request",
logContext: `${logLabel} request`,
}
);

View File

@ -1,5 +1,6 @@
import { Injectable, Inject, Optional } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type {
FreebitAccountDetailsResponse,
FreebitTrafficInfoResponse,
@ -72,7 +73,7 @@ export class FreebitMapperService {
async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> {
const account = response.responseDatas[0];
if (!account) {
throw new Error("No account data in response");
throw new FreebitOperationException("No account data in response");
}
// Debug: Log raw voice option fields from API response
@ -212,7 +213,7 @@ export class FreebitMapperService {
*/
mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage {
if (!response.traffic) {
throw new Error("No traffic data in response");
throw new FreebitOperationException("No traffic data in response");
}
const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0;
@ -237,7 +238,7 @@ export class FreebitMapperService {
*/
mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory {
if (!response.quotaHistory) {
throw new Error("No history data in response");
throw new FreebitOperationException("No history data in response");
}
return {

View File

@ -9,10 +9,16 @@
* JAPAN_POST_CLIENT_SECRET - OAuth client secret
*
* Optional Environment Variables:
* JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000)
* JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000)
* JAPAN_POST_DEFAULT_CLIENT_IP - Default client IP for x-forwarded-for (default: 127.0.0.1)
*/
import { Injectable, Inject, type OnModuleInit } from "@nestjs/common";
import {
Injectable,
Inject,
InternalServerErrorException,
type OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -26,6 +32,7 @@ interface JapanPostConfig {
clientId: string;
clientSecret: string;
timeout: number;
defaultClientIp: string;
}
interface ConfigValidationError {
@ -58,6 +65,8 @@ export class JapanPostConnectionService implements OnModuleInit {
clientId: this.configService.get<string>("JAPAN_POST_CLIENT_ID") || "",
clientSecret: this.configService.get<string>("JAPAN_POST_CLIENT_SECRET") || "",
timeout: this.configService.get<number>("JAPAN_POST_TIMEOUT") || 10000,
defaultClientIp:
this.configService.get<string>("JAPAN_POST_DEFAULT_CLIENT_IP") || "127.0.0.1",
};
// Validate configuration
@ -159,7 +168,7 @@ export class JapanPostConnectionService implements OnModuleInit {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-forwarded-for": "127.0.0.1", // Required by API
"x-forwarded-for": this.config.defaultClientIp, // Required by API
},
body: JSON.stringify({
grant_type: "client_credentials",
@ -186,7 +195,7 @@ export class JapanPostConnectionService implements OnModuleInit {
apiMessage: parsedError?.message,
hint: this.getErrorHint(response.status, parsedError?.error_code),
});
throw new Error(`Token request failed: HTTP ${response.status}`);
throw new InternalServerErrorException(`Token request failed: HTTP ${response.status}`);
}
const data = (await response.json()) as JapanPostTokenResponse;
@ -213,11 +222,13 @@ export class JapanPostConnectionService implements OnModuleInit {
timeoutMs: this.config.timeout,
durationMs,
});
throw new Error(`Token request timed out after ${this.config.timeout}ms`);
throw new InternalServerErrorException(
`Token request timed out after ${this.config.timeout}ms`
);
}
// Only log if not already logged above (non-ok response)
if (!(error instanceof Error && error.message.startsWith("Token request failed"))) {
if (!(error instanceof InternalServerErrorException)) {
this.logger.error("Japan Post token request error", {
endpoint: tokenUrl,
error: extractErrorMessage(error),
@ -298,7 +309,8 @@ export class JapanPostConnectionService implements OnModuleInit {
* @param clientIp - Client IP address for x-forwarded-for header
* @returns Raw Japan Post API response
*/
async searchByZipCode(zipCode: string, clientIp: string = "127.0.0.1"): Promise<unknown> {
async searchByZipCode(zipCode: string, clientIp?: string): Promise<unknown> {
const ip = clientIp || this.config.defaultClientIp;
const token = await this.getAccessToken();
const controller = new AbortController();
@ -314,7 +326,7 @@ export class JapanPostConnectionService implements OnModuleInit {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"x-forwarded-for": clientIp,
"x-forwarded-for": ip,
},
signal: controller.signal,
});
@ -337,7 +349,7 @@ export class JapanPostConnectionService implements OnModuleInit {
apiMessage: parsedError?.message,
hint: this.getErrorHint(response.status, parsedError?.error_code),
});
throw new Error(`ZIP code search failed: HTTP ${response.status}`);
throw new InternalServerErrorException(`ZIP code search failed: HTTP ${response.status}`);
}
const data = await response.json();
@ -360,11 +372,13 @@ export class JapanPostConnectionService implements OnModuleInit {
timeoutMs: this.config.timeout,
durationMs,
});
throw new Error(`ZIP search timed out after ${this.config.timeout}ms`);
throw new InternalServerErrorException(
`ZIP search timed out after ${this.config.timeout}ms`
);
}
// Only log if not already logged above (non-ok response)
if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) {
if (!(error instanceof InternalServerErrorException)) {
this.logger.error("Japan Post ZIP search error", {
zipCode,
endpoint: url,

View File

@ -16,6 +16,7 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../../services/salesforce-connection.service.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { PubSubClient, PubSubClientConstructor, PubSubCallback } from "./pubsub.types.js";
import { parseNumRequested } from "./pubsub.utils.js";
@ -63,7 +64,9 @@ export class PubSubClientService implements OnModuleDestroy {
const instanceUrl = this.sfConnection.getInstanceUrl();
if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token or instance URL missing for Pub/Sub client");
throw new SalesforceOperationException(
"Salesforce access token or instance URL missing for Pub/Sub client"
);
}
const pubSubEndpoint =

View File

@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "../salesforce-connection.service.js";
import { assertSalesforceId } from "../../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
import {
@ -19,6 +20,13 @@ import {
requireStringField,
} from "./opportunity.types.js";
/**
* Salesforce Opportunity record payload.
* Keys are dynamic (resolved from OPPORTUNITY_FIELD_MAP at runtime),
* so a static interface is not possible.
*/
type SalesforceOpportunityPayload = Record<string, unknown>;
@Injectable()
export class OpportunityCancellationService {
constructor(
@ -43,7 +51,8 @@ export class OpportunityCancellationService {
const safeData = (() => {
const unknownData: unknown = data;
if (!isRecord(unknownData)) throw new Error("Invalid cancellation data");
if (!isRecord(unknownData))
throw new SalesforceOperationException("Invalid cancellation data");
return {
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
@ -58,7 +67,7 @@ export class OpportunityCancellationService {
cancellationNotice: safeData.cancellationNotice,
});
const payload: Record<string, unknown> = {
const payload: SalesforceOpportunityPayload = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate,
@ -69,7 +78,9 @@ export class OpportunityCancellationService {
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -83,7 +94,7 @@ export class OpportunityCancellationService {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to update cancellation information");
throw new SalesforceOperationException("Failed to update cancellation information");
}
}
@ -103,7 +114,8 @@ export class OpportunityCancellationService {
const safeData = (() => {
const unknownData: unknown = data;
if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data");
if (!isRecord(unknownData))
throw new SalesforceOperationException("Invalid SIM cancellation data");
return {
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
@ -117,7 +129,7 @@ export class OpportunityCancellationService {
cancellationNotice: safeData.cancellationNotice,
});
const payload: Record<string, unknown> = {
const payload: SalesforceOpportunityPayload = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate,
@ -127,7 +139,9 @@ export class OpportunityCancellationService {
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -141,7 +155,7 @@ export class OpportunityCancellationService {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to update SIM cancellation information");
throw new SalesforceOperationException("Failed to update SIM cancellation information");
}
}
@ -155,7 +169,7 @@ export class OpportunityCancellationService {
opportunityId: safeOppId,
});
const payload: Record<string, unknown> = {
const payload: SalesforceOpportunityPayload = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED,
};
@ -163,7 +177,9 @@ export class OpportunityCancellationService {
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -176,7 +192,7 @@ export class OpportunityCancellationService {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to mark cancellation complete");
throw new SalesforceOperationException("Failed to mark cancellation complete");
}
}
}

View File

@ -11,6 +11,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "../salesforce-connection.service.js";
import { assertSalesforceId } from "../../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import {
type OpportunityStageValue,
type OpportunityProductTypeValue,
@ -22,6 +23,13 @@ import {
} from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
/**
* Salesforce Opportunity record payload.
* Keys are dynamic (resolved from OPPORTUNITY_FIELD_MAP at runtime),
* so a static interface is not possible.
*/
type SalesforceOpportunityPayload = Record<string, unknown>;
@Injectable()
export class OpportunityMutationService {
private readonly opportunityRecordTypeIds: Partial<
@ -66,7 +74,7 @@ export class OpportunityMutationService {
const commodityType = getDefaultCommodityType(request.productType);
const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType);
const payload: Record<string, unknown> = {
const payload: SalesforceOpportunityPayload = {
[OPPORTUNITY_FIELD_MAP.name]: opportunityName,
[OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId,
[OPPORTUNITY_FIELD_MAP.stage]: request.stage,
@ -83,13 +91,15 @@ export class OpportunityMutationService {
try {
const createMethod = this.sf.sobject("Opportunity").create;
if (!createMethod) {
throw new Error("Salesforce Opportunity create method not available");
throw new SalesforceOperationException(
"Salesforce Opportunity create method not available"
);
}
const result = (await createMethod(payload)) as { id?: string; success?: boolean };
if (!result?.id) {
throw new Error("Salesforce did not return Opportunity ID");
throw new SalesforceOperationException("Salesforce did not return Opportunity ID");
}
this.logger.log("Opportunity created successfully", {
@ -116,7 +126,7 @@ export class OpportunityMutationService {
}
this.logger.error(errorDetails, "Failed to create Opportunity");
throw new Error("Failed to create service lifecycle record");
throw new SalesforceOperationException("Failed to create service lifecycle record");
}
}
@ -136,7 +146,7 @@ export class OpportunityMutationService {
reason,
});
const payload: Record<string, unknown> = {
const payload: SalesforceOpportunityPayload = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: stage,
};
@ -144,7 +154,9 @@ export class OpportunityMutationService {
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -159,7 +171,7 @@ export class OpportunityMutationService {
opportunityId: safeOppId,
stage,
});
throw new Error("Failed to update service lifecycle stage");
throw new SalesforceOperationException("Failed to update service lifecycle stage");
}
}
@ -177,7 +189,7 @@ export class OpportunityMutationService {
whmcsServiceId,
});
const payload: Record<string, unknown> = {
const payload: SalesforceOpportunityPayload = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
[OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl]: `productselect=${whmcsServiceId}`,
@ -186,7 +198,9 @@ export class OpportunityMutationService {
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -220,7 +234,7 @@ export class OpportunityMutationService {
try {
const updateMethod = this.sf.sobject("Order").update;
if (!updateMethod) {
throw new Error("Salesforce Order update method not available");
throw new SalesforceOperationException("Salesforce Order update method not available");
}
await updateMethod({

View File

@ -2,11 +2,20 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { SalesforceConnection } from "./salesforce-connection.service.js";
import type { SalesforceAccountRecord } from "@customer-portal/domain/customer";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import { customerNumberSchema, salesforceIdSchema } from "@customer-portal/domain/common";
/**
* Salesforce Account/Contact record payloads.
* Mix of static Salesforce fields and dynamic keys from ConfigService
* (portal status, portal source, etc.), so a static interface is not possible.
*/
type SalesforceAccountPayload = Record<string, unknown>;
type SalesforceContactPayload = Record<string, unknown>;
/**
* Salesforce Account Service
*
@ -59,7 +68,7 @@ export class SalesforceAccountService {
this.logger.error("Failed to find account by customer number", {
error: extractErrorMessage(error),
});
throw new Error("Failed to find account");
throw new SalesforceOperationException("Failed to find account");
}
}
@ -90,7 +99,7 @@ export class SalesforceAccountService {
this.logger.error("Failed to find account with details by customer number", {
error: extractErrorMessage(error),
});
throw new Error("Failed to find account");
throw new SalesforceOperationException("Failed to find account");
}
}
@ -126,7 +135,7 @@ export class SalesforceAccountService {
accountId,
error: extractErrorMessage(error),
});
throw new Error("Failed to get account details");
throw new SalesforceOperationException("Failed to get account details");
}
}
@ -183,7 +192,7 @@ export class SalesforceAccountService {
const personAccountRecordTypeId = await this.resolvePersonAccountRecordTypeId();
const accountPayload: Record<string, unknown> = {
const accountPayload: SalesforceAccountPayload = {
// Person Account fields (required for Person Accounts)
FirstName: data.firstName,
LastName: data.lastName,
@ -191,7 +200,7 @@ export class SalesforceAccountService {
PersonMobilePhone: data.phone,
// Record type for Person Accounts (required)
RecordTypeId: personAccountRecordTypeId,
// Portal tracking fields
// Portal tracking fields (dynamic keys from ConfigService)
[this.portalStatusField]: data.portalStatus ?? "Not Yet",
[this.portalSourceField]: data.portalSource,
};
@ -206,13 +215,15 @@ export class SalesforceAccountService {
try {
const createMethod = this.connection.sobject("Account").create;
if (!createMethod) {
throw new Error("Salesforce create method not available");
throw new SalesforceOperationException("Salesforce create method not available");
}
const result = await createMethod(accountPayload);
if (!result || typeof result !== "object" || !("id" in result)) {
throw new Error("Salesforce Account creation failed - no ID returned");
throw new SalesforceOperationException(
"Salesforce Account creation failed - no ID returned"
);
}
const accountId = result.id as string;
@ -275,7 +286,7 @@ export class SalesforceAccountService {
},
"Failed to create Salesforce Account"
);
throw new Error("Failed to create customer account in CRM");
throw new SalesforceOperationException("Failed to create customer account in CRM");
}
}
@ -295,14 +306,14 @@ export class SalesforceAccountService {
)) as SalesforceResponse<{ Id: string; Name: string }>;
if (recordTypeQuery.totalSize === 0) {
throw new Error(
throw new SalesforceOperationException(
"No Person Account record type found. Person Accounts may not be enabled in this Salesforce org."
);
}
const record = recordTypeQuery.records[0];
if (!record) {
throw new Error("Person Account RecordType record not found");
throw new SalesforceOperationException("Person Account RecordType record not found");
}
const recordTypeId = record.Id;
this.logger.debug("Found Person Account RecordType", {
@ -314,7 +325,7 @@ export class SalesforceAccountService {
this.logger.error("Failed to query Person Account RecordType", {
error: extractErrorMessage(error),
});
throw new Error("Failed to determine Person Account record type");
throw new SalesforceOperationException("Failed to determine Person Account record type");
}
}
@ -349,11 +360,11 @@ export class SalesforceAccountService {
const personContactId = accountRecord.records[0]?.PersonContactId;
if (!personContactId) {
throw new Error("PersonContactId not found for Person Account");
throw new SalesforceOperationException("PersonContactId not found for Person Account");
}
// Update the PersonContact with additional fields
const contactPayload: Record<string, unknown> = {
const contactPayload: SalesforceContactPayload = {
Id: personContactId,
MobilePhone: data.phone,
Sex__c: mapGenderToSalesforce(data.gender),
@ -362,7 +373,7 @@ export class SalesforceAccountService {
const updateMethod = this.connection.sobject("Contact").update;
if (!updateMethod) {
throw new Error("Salesforce update method not available");
throw new SalesforceOperationException("Salesforce update method not available");
}
await updateMethod(contactPayload as Record<string, unknown> & { Id: string });
@ -374,7 +385,7 @@ export class SalesforceAccountService {
error: extractErrorMessage(error),
accountId: data.accountId,
});
throw new Error("Failed to update customer contact in CRM");
throw new SalesforceOperationException("Failed to update customer contact in CRM");
}
}
@ -417,7 +428,7 @@ export class SalesforceAccountService {
}
// Build contact update payload with Japanese mailing address fields
const contactPayload: Record<string, unknown> = {
const contactPayload: SalesforceContactPayload = {
Id: personContactId,
MailingStreet: address.mailingStreet || "",
MailingCity: address.mailingCity,
@ -466,7 +477,7 @@ export class SalesforceAccountService {
update: SalesforceAccountPortalUpdate
): Promise<void> {
const validAccountId = salesforceIdSchema.parse(accountId);
const payload: Record<string, unknown> = { Id: validAccountId };
const payload: SalesforceAccountPayload = { Id: validAccountId };
if (update.status) {
payload[this.portalStatusField] = update.status;

View File

@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { CASE_FIELDS } from "../constants/field-maps.js";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
@ -44,6 +45,13 @@ import {
// Types
// ============================================================================
/**
* Salesforce Case record payload.
* Keys are dynamic (resolved from CASE_FIELDS constant at runtime),
* so a static interface is not possible.
*/
type SalesforceCasePayload = Record<string, unknown>;
/**
* Parameters for creating any case in Salesforce.
*
@ -117,7 +125,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error),
accountId: safeAccountId,
});
throw new Error("Failed to fetch support cases");
throw new SalesforceOperationException("Failed to fetch support cases");
}
}
@ -154,7 +162,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error),
caseId: safeCaseId,
});
throw new Error("Failed to fetch support case");
throw new SalesforceOperationException("Failed to fetch support case");
}
}
@ -191,7 +199,7 @@ export class SalesforceCaseService {
? toSalesforcePriority(params.priority)
: SALESFORCE_CASE_PRIORITY.MEDIUM;
const casePayload: Record<string, unknown> = {
const casePayload: SalesforceCasePayload = {
[CASE_FIELDS.origin]: params.origin,
[CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW,
[CASE_FIELDS.priority]: sfPriority,
@ -221,7 +229,7 @@ export class SalesforceCaseService {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
throw new SalesforceOperationException("Salesforce did not return a case ID");
}
// Fetch the created case to get the CaseNumber
@ -241,7 +249,7 @@ export class SalesforceCaseService {
accountIdTail: safeAccountId.slice(-4),
origin: params.origin,
});
throw new Error("Failed to create case");
throw new SalesforceOperationException("Failed to create case");
}
}
@ -254,7 +262,7 @@ export class SalesforceCaseService {
async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> {
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
const casePayload: Record<string, unknown> = {
const casePayload: SalesforceCasePayload = {
[CASE_FIELDS.origin]: params.origin ?? "Web",
[CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW,
[CASE_FIELDS.priority]: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM,
@ -269,7 +277,7 @@ export class SalesforceCaseService {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
throw new SalesforceOperationException("Salesforce did not return a case ID");
}
// Fetch the created case to get the CaseNumber
@ -288,7 +296,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error),
email: params.suppliedEmail,
});
throw new Error("Failed to create contact request");
throw new SalesforceOperationException("Failed to create contact request");
}
}
@ -384,7 +392,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error),
caseId: safeCaseId,
});
throw new Error("Failed to fetch case messages");
throw new SalesforceOperationException("Failed to fetch case messages");
}
}
@ -414,7 +422,7 @@ export class SalesforceCaseService {
{ caseId: safeCaseId },
"Attempted to add comment to non-existent/unauthorized case"
);
throw new Error("Case not found");
throw new SalesforceOperationException("Case not found");
}
this.logger.log("Adding comment to case", {
@ -434,7 +442,7 @@ export class SalesforceCaseService {
};
if (!created.id) {
throw new Error("Salesforce did not return a comment ID");
throw new SalesforceOperationException("Salesforce did not return a comment ID");
}
const createdAt = new Date().toISOString();
@ -450,7 +458,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error),
caseId: safeCaseId,
});
throw new Error("Failed to add comment");
throw new SalesforceOperationException("Failed to add comment");
}
}
}

View File

@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId, buildInClause } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
import type {
SalesforceOrderItemRecord,
@ -28,6 +29,13 @@ import {
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import { SalesforceOrderFieldMapService } from "../config/order-field-map.service.js";
/**
* Salesforce Order record payload.
* Keys are dynamic (resolved from SalesforceOrderFieldMap at runtime),
* so a static interface is not possible.
*/
type SalesforceOrderPayload = Record<string, unknown>;
/**
* Salesforce Order Service
*
@ -114,7 +122,7 @@ export class SalesforceOrderService {
/**
* Create a new order in Salesforce
*/
async createOrder(orderFields: Record<string, unknown>): Promise<{ id: string }> {
async createOrder(orderFields: SalesforceOrderPayload): Promise<{ id: string }> {
const typeField = this.orderFieldMap.fields.order.type;
this.logger.log({ orderType: orderFields[typeField] }, "Creating Salesforce Order");
@ -132,7 +140,7 @@ export class SalesforceOrderService {
}
async createOrderWithItems(
orderFields: Record<string, unknown>,
orderFields: SalesforceOrderPayload,
items: Array<{ pricebookEntryId: string; unitPrice: number; quantity: number; sku?: string }>
): Promise<{ id: string }> {
if (!items.length) {
@ -172,7 +180,7 @@ export class SalesforceOrderService {
.map(err => `[${err.statusCode}] ${err.message}`)
.join("; ");
throw new Error(
throw new SalesforceOperationException(
errorDetails || "Salesforce composite tree returned errors during order creation"
);
}
@ -182,7 +190,9 @@ export class SalesforceOrderService {
);
if (!orderResult?.id) {
throw new Error("Salesforce composite tree response missing order ID");
throw new SalesforceOperationException(
"Salesforce composite tree response missing order ID"
);
}
this.logger.log(

View File

@ -65,6 +65,15 @@ interface SalesforceSIMInventoryResponse {
}>;
}
/** Payload for updating a SIM_Inventory__c record */
interface SimInventoryUpdatePayload {
Id: string;
Status__c: SimInventoryStatus;
Assigned_Account__c?: string;
Assigned_Order__c?: string;
SIM_Type__c?: string;
}
@Injectable()
export class SalesforceSIMInventoryService {
constructor(
@ -193,20 +202,20 @@ export class SalesforceSIMInventoryService {
});
try {
const updatePayload: Record<string, unknown> = {
const updatePayload: SimInventoryUpdatePayload = {
Id: safeId,
Status__c: SIM_INVENTORY_STATUS.ASSIGNED,
};
// Add optional assignment fields if provided
if (details?.accountId) {
updatePayload["Assigned_Account__c"] = details.accountId;
updatePayload.Assigned_Account__c = details.accountId;
}
if (details?.orderId) {
updatePayload["Assigned_Order__c"] = details.orderId;
updatePayload.Assigned_Order__c = details.orderId;
}
if (details?.simType) {
updatePayload["SIM_Type__c"] = details.simType;
updatePayload.SIM_Type__c = details.simType;
}
await this.sf.sobject("SIM_Inventory__c").update?.(updatePayload as { Id: string });

View File

@ -16,15 +16,11 @@ export class WhmcsErrorHandlerService {
/**
* Handle WHMCS API error response
*/
handleApiError(
errorResponse: WhmcsErrorResponse,
action: string,
params: Record<string, unknown>
): never {
handleApiError(errorResponse: WhmcsErrorResponse, action: string): never {
const message = errorResponse.message;
const errorCode = errorResponse.errorcode;
const mapped = this.mapProviderErrorToDomain(action, message, errorCode, params);
const mapped = this.mapProviderErrorToDomain(action, message, errorCode);
throw new DomainHttpException(mapped.code, mapped.status);
}
@ -172,8 +168,7 @@ export class WhmcsErrorHandlerService {
private mapProviderErrorToDomain(
action: string,
message: string,
providerErrorCode: string | undefined,
params: Record<string, unknown>
providerErrorCode: string | undefined
): { code: ErrorCodeType; status: HttpStatus } {
// 1) ValidateLogin: user credentials are wrong (expected)
if (
@ -199,7 +194,6 @@ export class WhmcsErrorHandlerService {
}
// 5) Default: external service error
void params; // reserved for future mapping detail; keep signature stable
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY };
}

View File

@ -1,6 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { redactForLogs } from "@bff/core/logging/redaction.util.js";
import type { WhmcsResponse } from "@customer-portal/domain/common/providers";
import type {
@ -140,7 +141,7 @@ export class WhmcsHttpClientService {
}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
throw new WhmcsOperationException(`HTTP ${response.status}: ${response.statusText}`);
}
return this.parseResponse<T>(responseText, action, params);
@ -242,7 +243,7 @@ export class WhmcsHttpClientService {
parseError: extractErrorMessage(parseError),
params: redactForLogs(params),
});
throw new Error("Invalid JSON response from WHMCS API");
throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
}
// Validate basic response structure
@ -255,7 +256,7 @@ export class WhmcsHttpClientService {
: { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params),
});
throw new Error("Invalid response structure from WHMCS API");
throw new WhmcsOperationException("Invalid response structure from WHMCS API");
}
// Handle error responses according to WHMCS API documentation

View File

@ -37,12 +37,48 @@ import type {
WhmcsProductListResponse,
} from "@customer-portal/domain/subscriptions/providers";
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services/providers";
import type { WhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers";
import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers";
import type {
WhmcsRequestOptions,
WhmcsConnectionStats,
} from "../connection/types/connection.types.js";
/**
* Parameters for WHMCS UpdateClient API.
* Any client field can be updated; these are the most common ones.
*/
interface WhmcsUpdateClientParams {
firstname?: string | undefined;
lastname?: string | undefined;
companyname?: string | undefined;
email?: string | undefined;
address1?: string | undefined;
address2?: string | undefined;
city?: string | undefined;
state?: string | undefined;
postcode?: string | undefined;
country?: string | undefined;
phonenumber?: string | undefined;
currency?: string | number | undefined;
language?: string | undefined;
status?: string | undefined;
notes?: string | undefined;
[key: string]: unknown;
}
/**
* Parameters for WHMCS GetOrders API.
*/
interface WhmcsGetOrdersParams {
id?: string;
userid?: number;
status?: string;
limitstart?: number;
limitnum?: number;
[key: string]: unknown;
}
/**
* WHMCS Connection Facade
*
@ -105,7 +141,7 @@ export class WhmcsConnectionFacade implements OnModuleInit {
if (response.result === "error") {
const errorResponse = response as WhmcsErrorResponse;
this.errorHandler.handleApiError(errorResponse, action, params);
this.errorHandler.handleApiError(errorResponse, action);
}
return response.data as T;
@ -189,7 +225,7 @@ export class WhmcsConnectionFacade implements OnModuleInit {
async updateClient(
clientId: number,
updateData: Record<string, unknown>
updateData: WhmcsUpdateClientParams
): Promise<{ result: string }> {
return this.makeRequest<{ result: string }>("UpdateClient", {
clientid: clientId,
@ -244,11 +280,11 @@ export class WhmcsConnectionFacade implements OnModuleInit {
// ORDER OPERATIONS (Used by order services)
// ==========================================
async addOrder(params: Record<string, unknown>) {
async addOrder(params: WhmcsAddOrderPayload) {
return this.makeRequest("AddOrder", params);
}
async getOrders(params: Record<string, unknown> = {}) {
async getOrders(params: WhmcsGetOrdersParams = {}) {
return this.makeRequest("GetOrders", params);
}
@ -323,10 +359,6 @@ export class WhmcsConnectionFacade implements OnModuleInit {
return this.configService.getBaseUrl();
}
// ==========================================
// UTILITY METHODS
// ==========================================
/**
* Determine request priority based on action type
*/

View File

@ -114,7 +114,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
// Check if response has currencies data (success case) or error fields
if (response.result === "success" || (response.currencies && !response["error"])) {
if (response.result === "success" || (response.currencies && response.result !== "error")) {
// Parse the WHMCS response format into currency objects
this.currencies = this.parseWhmcsCurrenciesResponse(response);
@ -135,13 +135,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
} else {
this.logger.error("WHMCS GetCurrencies returned error", {
result: response?.result,
message: response?.["message"],
error: response?.["error"],
errorcode: response?.["errorcode"],
message: response?.message,
errorcode: response?.errorcode,
fullResponse: JSON.stringify(response, null, 2),
});
throw new WhmcsOperationException(
`WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`,
`WHMCS GetCurrencies error: ${response?.message || "Unknown error"}`,
{ operation: "getCurrencies" }
);
}
@ -187,7 +186,9 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
}
} else {
// Fallback: try to parse flat format (currencies[currency][0][id], etc.)
const currencyKeys = Object.keys(response).filter(
// The flat format has dynamic keys not present in the typed schema — values are always strings
const flat = response as unknown as Record<string, string>;
const currencyKeys = Object.keys(flat).filter(
key => key.startsWith("currencies[currency][") && key.includes("][id]")
);
@ -203,12 +204,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
// Build currency objects from the flat response
for (const index of currencyIndices) {
const currency: Currency = {
id: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
code: String(response[`currencies[currency][${index}][code]`] || ""),
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),
format: String(response[`currencies[currency][${index}][format]`] || "1"),
rate: String(response[`currencies[currency][${index}][rate]`] || "1.00000"),
id: Number.parseInt(String(flat[`currencies[currency][${index}][id]`])) || 0,
code: String(flat[`currencies[currency][${index}][code]`] ?? ""),
prefix: String(flat[`currencies[currency][${index}][prefix]`] ?? ""),
suffix: String(flat[`currencies[currency][${index}][suffix]`] ?? ""),
format: String(flat[`currencies[currency][${index}][format]`] ?? "1"),
rate: String(flat[`currencies[currency][${index}][rate]`] ?? "1.00000"),
};
// Validate that we have essential currency data

View File

@ -4,20 +4,39 @@ import { WhmcsConnectionFacade } from "../facades/whmcs.facade.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type {
WhmcsOrderItem,
WhmcsAddOrderParams,
WhmcsAddOrderResponse,
WhmcsOrderResult,
} from "@customer-portal/domain/orders/providers";
import {
buildWhmcsAddOrderPayload,
whmcsAddOrderResponseSchema,
whmcsAcceptOrderResponseSchema,
type WhmcsOrderItem,
type WhmcsAddOrderParams,
type WhmcsAddOrderResponse,
type WhmcsOrderResult,
type WhmcsAddOrderPayload,
type WhmcsAcceptOrderResponse,
} from "@customer-portal/domain/orders/providers";
export type { WhmcsOrderItem, WhmcsAddOrderParams, WhmcsOrderResult };
/** Raw WHMCS order record from GetOrders API */
interface WhmcsGetOrdersOrder {
id?: string | number;
ordernum?: string;
date?: string;
status?: string;
invoiceid?: string | number;
[key: string]: unknown;
}
/** Raw WHMCS GetOrders API response */
interface WhmcsGetOrdersResponse {
orders?: {
order?: WhmcsGetOrdersOrder[];
};
totalresults?: number;
[key: string]: unknown;
}
@Injectable()
export class WhmcsOrderService {
constructor(
@ -47,16 +66,14 @@ export class WhmcsOrderService {
this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId,
productCount: Array.isArray(addOrderPayload["pid"])
? (addOrderPayload["pid"] as unknown[]).length
: 0,
pids: addOrderPayload["pid"],
quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added
billingCycles: addOrderPayload["billingcycle"],
hasConfigOptions: Boolean(addOrderPayload["configoptions"]),
hasCustomFields: Boolean(addOrderPayload["customfields"]),
promoCode: addOrderPayload["promocode"],
paymentMethod: addOrderPayload["paymentmethod"],
productCount: addOrderPayload.pid.length,
pids: addOrderPayload.pid,
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
billingCycles: addOrderPayload.billingcycle,
hasConfigOptions: Boolean(addOrderPayload.configoptions),
hasCustomFields: Boolean(addOrderPayload.customfields),
promoCode: addOrderPayload.promocode,
paymentMethod: addOrderPayload.paymentmethod,
});
// Call WHMCS AddOrder API
@ -135,7 +152,7 @@ export class WhmcsOrderService {
// Call WHMCS AcceptOrder API
// Note: The HTTP client throws errors automatically if result === "error"
// So we only get here if the request was successful
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
const response = (await this.connection.acceptOrder(orderId)) as WhmcsAcceptOrderResponse;
// Log the full response for debugging
this.logger.debug("WHMCS AcceptOrder response", {
@ -248,15 +265,14 @@ export class WhmcsOrderService {
/**
* Get order details from WHMCS
*/
async getOrderDetails(orderId: number): Promise<Record<string, unknown> | null> {
async getOrderDetails(orderId: number): Promise<WhmcsGetOrdersOrder | null> {
try {
// Note: The HTTP client throws errors automatically if result === "error"
const response = (await this.connection.getOrders({
id: orderId.toString(),
})) as Record<string, unknown>;
})) as WhmcsGetOrdersResponse;
const orders = response["orders"] as { order?: Record<string, unknown>[] } | undefined;
return orders?.order?.[0] ?? null;
return response.orders?.order?.[0] ?? null;
} catch (error) {
this.logger.error("Failed to get WHMCS order details", {
error: extractErrorMessage(error),
@ -302,19 +318,19 @@ export class WhmcsOrderService {
*
* Delegates to shared mapper function from integration package
*/
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> {
private buildAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload {
const payload = buildWhmcsAddOrderPayload(params);
this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId,
productCount: params.items.length,
pids: payload["pid"],
billingCycles: payload["billingcycle"],
hasConfigOptions: !!payload["configoptions"],
hasCustomFields: !!payload["customfields"],
pids: payload.pid,
billingCycles: payload.billingcycle,
hasConfigOptions: !!payload.configoptions,
hasCustomFields: !!payload.customfields,
});
return payload as Record<string, unknown>;
return payload;
}
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {

View File

@ -15,6 +15,8 @@ import { TokenRevocationService } from "./infra/token/token-revocation.service.j
import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { AuthTokenService } from "./infra/token/token.service.js";
import { TokenGeneratorService } from "./infra/token/token-generator.service.js";
import { TokenRefreshService } from "./infra/token/token-refresh.service.js";
import { JoseJwtService } from "./infra/token/jose-jwt.service.js";
import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js";
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
@ -62,6 +64,8 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv
TokenBlacklistService,
TokenStorageService,
TokenRevocationService,
TokenGeneratorService,
TokenRefreshService,
AuthTokenService,
JoseJwtService,
PasswordResetTokenService,

View File

@ -5,6 +5,8 @@
*/
export { AuthTokenService } from "./token.service.js";
export { TokenGeneratorService } from "./token-generator.service.js";
export { TokenRefreshService } from "./token-refresh.service.js";
export { JoseJwtService } from "./jose-jwt.service.js";
export { TokenBlacklistService } from "./token-blacklist.service.js";
export { TokenStorageService } from "./token-storage.service.js";

View File

@ -0,0 +1,214 @@
import {
BadRequestException,
Injectable,
Inject,
ServiceUnavailableException,
} from "@nestjs/common";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserRole } from "@customer-portal/domain/customer";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
interface RefreshTokenPayload extends JWTPayload {
userId: string;
familyId?: string | undefined;
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/**
* Token Generator Service
*
* Handles all token creation logic: access tokens, refresh tokens, and token pairs.
*/
@Injectable()
export class TokenGeneratorService {
readonly ACCESS_TOKEN_EXPIRY = "15m";
readonly REFRESH_TOKEN_EXPIRY = "7d";
constructor(
private readonly jwtService: JoseJwtService,
private readonly storage: TokenStorageService,
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Generate a new token pair with refresh token storage
*/
async generateTokenPair(
user: {
id: string;
email: string;
role?: UserRole;
},
deviceInfo?: DeviceInfo
): Promise<AuthTokens> {
if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) {
this.logger.error("Invalid user ID provided for token generation", {
userId: user.id,
});
throw new BadRequestException("Invalid user ID for token generation");
}
if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) {
this.logger.error("Invalid user email provided for token generation", {
userId: user.id,
});
throw new BadRequestException("Invalid user email for token generation");
}
const accessTokenId = this.generateTokenId();
const refreshFamilyId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
const refreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId: refreshFamilyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
const refreshTokenHash = this.hashToken(refreshToken);
const refreshAbsoluteExpiresAt = new Date(
Date.now() + refreshExpirySeconds * 1000
).toISOString();
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", {
status: this.redis.status,
});
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
try {
await this.storage.storeRefreshToken({
userId: user.id,
familyId: refreshFamilyId,
refreshTokenHash,
deviceInfo,
refreshExpirySeconds,
absoluteExpiresAt: refreshAbsoluteExpiresAt,
});
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id,
});
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
const refreshExpiresAt = refreshAbsoluteExpiresAt;
this.logger.debug("Generated new token pair", {
userId: user.id,
accessTokenId,
refreshFamilyId,
refreshTokenId,
});
return {
accessToken,
refreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
};
}
/**
* Generate new access and refresh tokens for rotation
*/
async generateRotationTokenPair(
user: { id: string; email: string; role: string },
familyId: string,
remainingSeconds: number,
deviceInfo?: DeviceInfo
): Promise<{ newAccessToken: string; newRefreshToken: string; newRefreshTokenHash: string }> {
const accessTokenId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
const newRefreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
const newRefreshTokenHash = this.hashToken(newRefreshToken);
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
}
generateTokenId(): string {
return randomBytes(32).toString("hex");
}
hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1);
const value = Number.parseInt(expiry.slice(0, -1));
switch (unit) {
case "s":
return value * 1000;
case "m":
return value * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 15 * 60 * 1000;
}
}
parseExpiryToSeconds(expiry: string): number {
return Math.floor(this.parseExpiryToMs(expiry) / 1000);
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Redis } from "ioredis";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -53,7 +53,7 @@ export class TokenMigrationService {
this.logger.log("Starting token migration", { dryRun });
if (this.redis.status !== "ready") {
throw new Error("Redis is not ready for migration");
throw new ServiceUnavailableException("Redis is not ready for migration");
}
try {

View File

@ -0,0 +1,336 @@
import {
Injectable,
Inject,
UnauthorizedException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
import { JoseJwtService } from "./jose-jwt.service.js";
interface RefreshTokenPayload extends JWTPayload {
userId: string;
familyId?: string | undefined;
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";
interface ValidatedTokenContext {
payload: RefreshTokenPayload;
familyId: string;
refreshTokenHash: string;
remainingSeconds: number;
absoluteExpiresAt: string;
createdAt: string;
}
/**
* Token Refresh Service
*
* Handles refresh token validation, rotation, and re-issuance.
*/
@Injectable()
export class TokenRefreshService {
private readonly allowRedisFailOpen: boolean;
constructor(
private readonly generator: TokenGeneratorService,
private readonly storage: TokenStorageService,
private readonly revocation: TokenRevocationService,
private readonly jwtService: JoseJwtService,
private readonly usersService: UsersService,
configService: ConfigService,
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {
this.allowRedisFailOpen =
configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
}
/**
* Refresh access token using refresh token rotation
*/
async refreshTokens(
refreshToken: string,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
if (!refreshToken) {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
this.checkRedisForRefresh();
try {
const tokenContext = await this.validateAndExtractTokenContext(refreshToken);
return await this.performTokenRotation(tokenContext, deviceInfo);
} catch (error) {
return this.handleRefreshError(error);
}
}
private checkRedisForRefresh(): void {
if (!this.allowRedisFailOpen && this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh", {
redisStatus: this.redis.status,
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
}
private async validateAndExtractTokenContext(
refreshToken: string
): Promise<ValidatedTokenContext> {
const payload = await this.verifyRefreshTokenPayload(refreshToken);
const familyId = this.extractFamilyId(payload);
const refreshTokenHash = this.generator.hashToken(refreshToken);
await this.validateStoredToken(refreshTokenHash, familyId, payload);
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
familyId,
refreshTokenHash
);
return {
payload,
familyId,
refreshTokenHash,
remainingSeconds,
absoluteExpiresAt,
createdAt,
};
}
private async verifyRefreshTokenPayload(refreshToken: string): Promise<RefreshTokenPayload> {
const payload = await this.jwtService.verify<RefreshTokenPayload>(refreshToken);
if (payload.type !== "refresh") {
this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
tokenId: payload.tokenId,
});
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.userId || typeof payload.userId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.tokenId || typeof payload.tokenId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
return payload;
}
private extractFamilyId(payload: RefreshTokenPayload): string {
return typeof payload.familyId === "string" && payload.familyId.length > 0
? payload.familyId
: payload.tokenId;
}
private async validateStoredToken(
refreshTokenHash: string,
familyId: string,
payload: RefreshTokenPayload
): Promise<void> {
const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily(
refreshTokenHash,
familyId
);
if (!storedTokenData) {
this.logger.warn("Refresh token not found or expired", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData);
if (!tokenRecord) {
this.logger.warn("Stored refresh token payload was invalid JSON", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.storage.deleteToken(refreshTokenHash);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) {
this.logger.warn("Refresh token record mismatch", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!tokenRecord.valid) {
this.logger.warn("Refresh token marked as invalid", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
if (family && family.tokenHash !== refreshTokenHash) {
this.logger.warn("Refresh token does not match current family token", {
familyId: familyId.slice(0, 8),
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
}
private async calculateTokenLifetime(
familyId: string,
refreshTokenHash: string
): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> {
const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId);
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
let remainingSeconds: number | null = null;
let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt;
if (absoluteExpiresAt) {
const absMs = Date.parse(absoluteExpiresAt);
if (Number.isNaN(absMs)) {
absoluteExpiresAt = undefined;
} else {
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
}
}
if (remainingSeconds === null) {
remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash);
absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString();
}
if (!remainingSeconds || remainingSeconds <= 0) {
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const createdAt = family?.createdAt ?? new Date().toISOString();
const expiresAt =
absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString();
return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt };
}
private async calculateRemainingSecondsFromTtl(
familyId: string,
refreshTokenHash: string
): Promise<number> {
const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`;
const ttl = await this.storage.getTtl(familyKey);
if (typeof ttl === "number" && ttl > 0) {
return ttl;
}
const tokenTtl = await this.storage.getTtl(tokenKey);
return typeof tokenTtl === "number" && tokenTtl > 0
? tokenTtl
: this.generator.parseExpiryToSeconds(this.generator.REFRESH_TOKEN_EXPIRY);
}
private async performTokenRotation(
context: ValidatedTokenContext,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
const user = await this.usersService.findByIdInternal(context.payload.userId);
if (!user) {
this.logger.warn("User not found during token refresh", { userId: context.payload.userId });
throw new UnauthorizedException("User not found");
}
const userProfile = mapPrismaUserToDomain(user);
const { newAccessToken, newRefreshToken, newRefreshTokenHash } =
await this.generator.generateRotationTokenPair(
user,
context.familyId,
context.remainingSeconds,
deviceInfo
);
const refreshExpiresAt =
context.absoluteExpiresAt ??
new Date(Date.now() + context.remainingSeconds * 1000).toISOString();
const rotationResult = await this.storage.atomicTokenRotation({
oldTokenHash: context.refreshTokenHash,
newTokenHash: newRefreshTokenHash,
familyId: context.familyId,
userId: user.id,
deviceInfo,
createdAt: context.createdAt,
absoluteExpiresAt: refreshExpiresAt,
ttlSeconds: context.remainingSeconds,
});
if (!rotationResult.success) {
this.logger.warn("Atomic token rotation failed - possible concurrent refresh", {
error: rotationResult.error,
familyId: context.familyId.slice(0, 8),
tokenHash: context.refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(context.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const accessExpiresAt = new Date(
Date.now() + this.generator.parseExpiryToMs(this.generator.ACCESS_TOKEN_EXPIRY)
).toISOString();
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });
return {
tokens: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
},
user: userProfile,
};
}
private handleRefreshError(error: unknown): never {
if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) {
throw error;
}
this.logger.error("Token refresh failed with unexpected error", {
error: error instanceof Error ? error.message : String(error),
});
if (this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh - failing closed for security", {
redisStatus: this.redis.status,
securityReason: "refresh_token_rotation_requires_redis",
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
}

View File

@ -1,26 +1,14 @@
import {
Injectable,
Inject,
UnauthorizedException,
ServiceUnavailableException,
} from "@nestjs/common";
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth, UserRole } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenRefreshService } from "./token-refresh.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";
export interface RefreshTokenPayload extends JWTPayload {
userId: string;
/**
@ -38,76 +26,46 @@ export interface RefreshTokenPayload extends JWTPayload {
type: "refresh";
}
interface DeviceInfo {
export interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
interface ValidatedTokenContext {
payload: RefreshTokenPayload;
familyId: string;
refreshTokenHash: string;
remainingSeconds: number;
absoluteExpiresAt: string;
createdAt: string;
}
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/**
* Auth Token Service
*
* Handles token generation and refresh operations.
* Delegates storage operations to TokenStorageService.
* Delegates revocation operations to TokenRevocationService.
* Thin orchestrator that delegates to focused services:
* - TokenGeneratorService: token creation
* - TokenRefreshService: refresh + rotation logic
* - TokenRevocationService: token revocation
*
* Preserves the existing public API so consumers don't need changes.
*/
@Injectable()
export class AuthTokenService {
private readonly ACCESS_TOKEN_EXPIRY = "15m";
private readonly REFRESH_TOKEN_EXPIRY = "7d";
private readonly allowRedisFailOpen: boolean;
private readonly requireRedisForTokens: boolean;
private readonly maintenanceMode: boolean;
private readonly maintenanceMessage: string;
private readonly jwtService: JoseJwtService;
private readonly configService: ConfigService;
private readonly storage: TokenStorageService;
private readonly revocation: TokenRevocationService;
private readonly redis: Redis;
private readonly logger: Logger;
private readonly usersService: UsersService;
// eslint-disable-next-line max-params
constructor(
jwtService: JoseJwtService,
private readonly generator: TokenGeneratorService,
private readonly refreshService: TokenRefreshService,
private readonly revocation: TokenRevocationService,
configService: ConfigService,
storage: TokenStorageService,
revocation: TokenRevocationService,
@Inject("REDIS_CLIENT") redis: Redis,
@Inject(Logger) logger: Logger,
usersService: UsersService
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {
this.jwtService = jwtService;
this.configService = configService;
this.storage = storage;
this.revocation = revocation;
this.redis = redis;
this.logger = logger;
this.usersService = usersService;
this.allowRedisFailOpen =
this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
this.requireRedisForTokens =
this.configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
this.maintenanceMode = this.configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
this.maintenanceMessage = this.configService.get(
configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
this.maintenanceMode = configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
this.maintenanceMessage = configService.get(
"AUTH_MAINTENANCE_MESSAGE",
"Authentication service is temporarily unavailable for maintenance. Please try again later."
);
}
/**
* Check if authentication service is available
*/
private checkServiceAvailability(): void {
if (this.maintenanceMode) {
this.logger.warn("Authentication service in maintenance mode", {
@ -129,113 +87,11 @@ export class AuthTokenService {
* Generate a new token pair with refresh token rotation
*/
async generateTokenPair(
user: {
id: string;
email: string;
role?: UserRole;
},
user: { id: string; email: string; role?: UserRole },
deviceInfo?: DeviceInfo
): Promise<AuthTokens> {
// Validate required user fields
if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) {
this.logger.error("Invalid user ID provided for token generation", {
userId: user.id,
});
throw new Error("Invalid user ID for token generation");
}
if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) {
this.logger.error("Invalid user email provided for token generation", {
userId: user.id,
});
throw new Error("Invalid user email for token generation");
}
this.checkServiceAvailability();
const accessTokenId = this.generateTokenId();
const refreshFamilyId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
// Create access token payload
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
// Create refresh token payload
const refreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId: refreshFamilyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
// Generate tokens
const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
// Store refresh token in Redis
const refreshTokenHash = this.hashToken(refreshToken);
const refreshAbsoluteExpiresAt = new Date(
Date.now() + refreshExpirySeconds * 1000
).toISOString();
// Store refresh token in Redis - this is required for secure token rotation
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", {
status: this.redis.status,
requireRedisForTokens: this.requireRedisForTokens,
});
// Always fail if Redis is unavailable - tokens without storage cannot be
// securely rotated or revoked, creating a security vulnerability
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
try {
await this.storage.storeRefreshToken({
userId: user.id,
familyId: refreshFamilyId,
refreshTokenHash,
deviceInfo,
refreshExpirySeconds,
absoluteExpiresAt: refreshAbsoluteExpiresAt,
});
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id,
});
// Always fail on storage error - issuing tokens that can't be validated
// or rotated creates a security vulnerability
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
const refreshExpiresAt = refreshAbsoluteExpiresAt;
this.logger.debug("Generated new token pair", {
userId: user.id,
accessTokenId,
refreshFamilyId,
refreshTokenId,
});
return {
accessToken,
refreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
};
return this.generator.generateTokenPair(user, deviceInfo);
}
/**
@ -245,323 +101,8 @@ export class AuthTokenService {
refreshToken: string,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
if (!refreshToken) {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
this.checkServiceAvailability();
this.checkRedisForRefresh();
try {
const tokenContext = await this.validateAndExtractTokenContext(refreshToken);
return await this.performTokenRotation(tokenContext, deviceInfo);
} catch (error) {
return this.handleRefreshError(error);
}
}
/**
* Check Redis availability for refresh operations
*/
private checkRedisForRefresh(): void {
if (!this.allowRedisFailOpen && this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh", {
redisStatus: this.redis.status,
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
}
/**
* Validate refresh token and extract context for rotation
*/
private async validateAndExtractTokenContext(
refreshToken: string
): Promise<ValidatedTokenContext> {
const payload = await this.verifyRefreshTokenPayload(refreshToken);
const familyId = this.extractFamilyId(payload);
const refreshTokenHash = this.hashToken(refreshToken);
await this.validateStoredToken(refreshTokenHash, familyId, payload);
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
familyId,
refreshTokenHash
);
return {
payload,
familyId,
refreshTokenHash,
remainingSeconds,
absoluteExpiresAt,
createdAt,
};
}
/**
* Verify JWT and validate payload structure
*/
private async verifyRefreshTokenPayload(refreshToken: string): Promise<RefreshTokenPayload> {
const payload = await this.jwtService.verify<RefreshTokenPayload>(refreshToken);
if (payload.type !== "refresh") {
this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
tokenId: payload.tokenId,
});
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.userId || typeof payload.userId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.tokenId || typeof payload.tokenId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
return payload;
}
/**
* Extract family ID from payload (supports legacy tokens)
*/
private extractFamilyId(payload: RefreshTokenPayload): string {
return typeof payload.familyId === "string" && payload.familyId.length > 0
? payload.familyId
: payload.tokenId;
}
/**
* Validate stored token data against payload
*/
private async validateStoredToken(
refreshTokenHash: string,
familyId: string,
payload: RefreshTokenPayload
): Promise<void> {
const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily(
refreshTokenHash,
familyId
);
if (!storedTokenData) {
this.logger.warn("Refresh token not found or expired", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData);
if (!tokenRecord) {
this.logger.warn("Stored refresh token payload was invalid JSON", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.storage.deleteToken(refreshTokenHash);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) {
this.logger.warn("Refresh token record mismatch", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!tokenRecord.valid) {
this.logger.warn("Refresh token marked as invalid", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
if (family && family.tokenHash !== refreshTokenHash) {
this.logger.warn("Refresh token does not match current family token", {
familyId: familyId.slice(0, 8),
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
}
/**
* Calculate remaining lifetime for refresh token
*/
private async calculateTokenLifetime(
familyId: string,
refreshTokenHash: string
): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> {
const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId);
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
let remainingSeconds: number | null = null;
let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt;
if (absoluteExpiresAt) {
const absMs = Date.parse(absoluteExpiresAt);
if (Number.isNaN(absMs)) {
absoluteExpiresAt = undefined;
} else {
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
}
}
if (remainingSeconds === null) {
remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash);
absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString();
}
if (!remainingSeconds || remainingSeconds <= 0) {
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const createdAt = family?.createdAt ?? new Date().toISOString();
const expiresAt =
absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString();
return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt };
}
/**
* Calculate remaining seconds from Redis TTL
*/
private async calculateRemainingSecondsFromTtl(
familyId: string,
refreshTokenHash: string
): Promise<number> {
const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`;
const ttl = await this.storage.getTtl(familyKey);
if (typeof ttl === "number" && ttl > 0) {
return ttl;
}
const tokenTtl = await this.storage.getTtl(tokenKey);
return typeof tokenTtl === "number" && tokenTtl > 0
? tokenTtl
: this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
}
/**
* Perform atomic token rotation and generate new token pair
*/
private async performTokenRotation(
context: ValidatedTokenContext,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
const user = await this.usersService.findByIdInternal(context.payload.userId);
if (!user) {
this.logger.warn("User not found during token refresh", { userId: context.payload.userId });
throw new UnauthorizedException("User not found");
}
const userProfile = mapPrismaUserToDomain(user);
const { newAccessToken, newRefreshToken, newRefreshTokenHash } =
await this.generateNewTokenPair(user, context, deviceInfo);
const refreshExpiresAt =
context.absoluteExpiresAt ??
new Date(Date.now() + context.remainingSeconds * 1000).toISOString();
const rotationResult = await this.storage.atomicTokenRotation({
oldTokenHash: context.refreshTokenHash,
newTokenHash: newRefreshTokenHash,
familyId: context.familyId,
userId: user.id,
deviceInfo,
createdAt: context.createdAt,
absoluteExpiresAt: refreshExpiresAt,
ttlSeconds: context.remainingSeconds,
});
if (!rotationResult.success) {
this.logger.warn("Atomic token rotation failed - possible concurrent refresh", {
error: rotationResult.error,
familyId: context.familyId.slice(0, 8),
tokenHash: context.refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(context.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });
return {
tokens: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
},
user: userProfile,
};
}
/**
* Generate new access and refresh tokens
*/
private async generateNewTokenPair(
user: { id: string; email: string; role: string },
context: ValidatedTokenContext,
deviceInfo?: DeviceInfo
): Promise<{ newAccessToken: string; newRefreshToken: string; newRefreshTokenHash: string }> {
const accessTokenId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
const newRefreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId: context.familyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, context.remainingSeconds);
const newRefreshTokenHash = this.hashToken(newRefreshToken);
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
}
/**
* Handle errors during token refresh
*/
private handleRefreshError(error: unknown): never {
if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) {
throw error;
}
this.logger.error("Token refresh failed with unexpected error", {
error: error instanceof Error ? error.message : String(error),
});
if (this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh - failing closed for security", {
redisStatus: this.redis.status,
securityReason: "refresh_token_rotation_requires_redis",
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
return this.refreshService.refreshTokens(refreshToken, deviceInfo);
}
/**
@ -591,34 +132,4 @@ export class AuthTokenService {
async revokeAllUserTokens(userId: string): Promise<void> {
return this.revocation.revokeAllUserTokens(userId);
}
private generateTokenId(): string {
return randomBytes(32).toString("hex");
}
private hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
private parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1);
const value = Number.parseInt(expiry.slice(0, -1));
switch (unit) {
case "s":
return value * 1000;
case "m":
return value * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 15 * 60 * 1000;
}
}
private parseExpiryToSeconds(expiry: string): number {
return Math.floor(this.parseExpiryToMs(expiry) / 1000);
}
}

View File

@ -2,6 +2,7 @@ import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
UnauthorizedException,
NotFoundException,
} from "@nestjs/common";
@ -88,7 +89,7 @@ export class PasswordWorkflowService {
}
const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) {
throw new Error("Failed to load user after password setup");
throw new InternalServerErrorException("Failed to load user after password setup");
}
const userProfile = mapPrismaUserToDomain(prismaUser);
@ -106,7 +107,7 @@ export class PasswordWorkflowService {
criticality: OperationCriticality.CRITICAL,
context: `Set password for user ${user.id}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
rethrow: [NotFoundException, BadRequestException, InternalServerErrorException],
fallbackMessage: "Failed to set password",
}
);
@ -163,7 +164,7 @@ export class PasswordWorkflowService {
await this.usersService.update(prismaUser.id, { passwordHash });
const freshUser = await this.usersService.findByIdInternal(prismaUser.id);
if (!freshUser) {
throw new Error("Failed to load user after password reset");
throw new InternalServerErrorException("Failed to load user after password reset");
}
// Force re-login everywhere after password reset
await this.tokenService.revokeAllUserTokens(freshUser.id);
@ -174,7 +175,7 @@ export class PasswordWorkflowService {
criticality: OperationCriticality.CRITICAL,
context: "Reset password",
logger: this.logger,
rethrow: [BadRequestException],
rethrow: [BadRequestException, InternalServerErrorException],
fallbackMessage: "Failed to reset password",
}
);
@ -221,7 +222,7 @@ export class PasswordWorkflowService {
await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) {
throw new Error("Failed to load user after password change");
throw new InternalServerErrorException("Failed to load user after password change");
}
const userProfile = mapPrismaUserToDomain(prismaUser);
@ -251,7 +252,7 @@ export class PasswordWorkflowService {
criticality: OperationCriticality.CRITICAL,
context: `Change password for user ${user.id}`,
logger: this.logger,
rethrow: [NotFoundException, BadRequestException],
rethrow: [NotFoundException, BadRequestException, InternalServerErrorException],
fallbackMessage: "Failed to change password",
}
);

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
@ -36,7 +36,7 @@ export class GenerateAuthResultStep {
// Load fresh user from DB
const freshUser = await this.usersService.findByIdInternal(userId);
if (!freshUser) {
throw new Error("Failed to load created user");
throw new InternalServerErrorException("Failed to load created user");
}
// Log audit event

View File

@ -3,6 +3,7 @@ import {
ConflictException,
Inject,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
@ -110,7 +111,7 @@ export class WhmcsLinkWorkflowService {
const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
if (!prismaUser) {
throw new Error("Failed to load newly linked user");
throw new InternalServerErrorException("Failed to load newly linked user");
}
const userProfile: User = mapPrismaUserToDomain(prismaUser);
@ -137,7 +138,12 @@ export class WhmcsLinkWorkflowService {
criticality: OperationCriticality.CRITICAL,
context: "WHMCS account linking",
logger: this.logger,
rethrow: [BadRequestException, ConflictException, UnauthorizedException],
rethrow: [
BadRequestException,
ConflictException,
InternalServerErrorException,
UnauthorizedException,
],
fallbackMessage: "Failed to link WHMCS account",
}
);

View File

@ -23,6 +23,12 @@ import { GetStartedSessionService } from "../otp/get-started-session.service.js"
import { SignupUserCreationService } from "./signup/signup-user-creation.service.js";
import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js";
/** WHMCS client update payload for account migration (password + optional custom fields) */
interface WhmcsMigrationClientUpdate {
password2: string;
customfields?: string;
}
/**
* WHMCS Migration Workflow Service
*
@ -226,15 +232,20 @@ export class WhmcsMigrationWorkflowService {
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
const updateData: Record<string, unknown> = {
const updateData: WhmcsMigrationClientUpdate = {
password2: password,
};
if (Object.keys(customfieldsMap).length > 0) {
updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap);
updateData.customfields = serializeWhmcsKeyValueMap(customfieldsMap);
}
await this.whmcsClientService.updateClient(clientId, updateData);
// customfields is sent as base64-encoded serialized PHP array to the WHMCS API,
// which differs from the parsed client schema type — cast is intentional
await this.whmcsClientService.updateClient(
clientId,
updateData as unknown as Parameters<typeof this.whmcsClientService.updateClient>[1]
);
this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data");
}

View File

@ -1,5 +1,6 @@
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common";
import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
@ -30,13 +31,12 @@ type RequestWithRoute = RequestWithCookies & {
@Injectable()
export class GlobalAuthGuard implements CanActivate {
private readonly logger = new Logger(GlobalAuthGuard.name);
constructor(
private reflector: Reflector,
private readonly tokenBlacklistService: TokenBlacklistService,
private readonly jwtService: JoseJwtService,
private readonly usersService: UsersService
private readonly usersService: UsersService,
@Inject(Logger) private readonly logger: Logger
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {

View File

@ -1,5 +1,6 @@
import { Injectable, ForbiddenException, Logger } from "@nestjs/common";
import { Injectable, Inject, ForbiddenException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
@ -24,9 +25,10 @@ type RequestWithUser = Request & { user?: UserWithRole };
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
private readonly logger = new Logger(PermissionsGuard.name);
constructor(private readonly reflector: Reflector) {}
constructor(
private readonly reflector: Reflector,
@Inject(Logger) private readonly logger: Logger
) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>(

View File

@ -6,7 +6,12 @@
* and displayed alongside email notifications.
*/
import { Injectable, Inject } from "@nestjs/common";
import {
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import type { Notification as PrismaNotification } from "@prisma/client";
@ -57,7 +62,7 @@ export class NotificationService {
async createNotification(params: CreateNotificationParams): Promise<Notification> {
const template = NOTIFICATION_TEMPLATES[params.type];
if (!template) {
throw new Error(`Unknown notification type: ${params.type}`);
throw new BadRequestException(`Unknown notification type: ${params.type}`);
}
// Calculate expiry date (30 days from now)
@ -116,7 +121,7 @@ export class NotificationService {
userId: params.userId,
type: params.type,
});
throw new Error("Failed to create notification");
throw new InternalServerErrorException("Failed to create notification");
}
}
@ -173,7 +178,7 @@ export class NotificationService {
error: extractErrorMessage(error),
userId,
});
throw new Error("Failed to get notifications");
throw new InternalServerErrorException("Failed to get notifications");
}
}
@ -221,7 +226,7 @@ export class NotificationService {
notificationId,
userId,
});
throw new Error("Failed to update notification");
throw new InternalServerErrorException("Failed to update notification");
}
}
@ -244,7 +249,7 @@ export class NotificationService {
error: extractErrorMessage(error),
userId,
});
throw new Error("Failed to update notifications");
throw new InternalServerErrorException("Failed to update notifications");
}
}
@ -268,7 +273,7 @@ export class NotificationService {
notificationId,
userId,
});
throw new Error("Failed to dismiss notification");
throw new InternalServerErrorException("Failed to dismiss notification");
}
}

View File

@ -63,17 +63,7 @@ export class CheckoutController {
req.user?.id
);
const session = await this.checkoutSessions.createSession(body, cart);
return {
sessionId: session.sessionId,
expiresAt: session.expiresAt,
orderType: body.orderType,
cart: {
items: cart.items,
totals: cart.totals,
},
};
return this.checkoutSessions.createSessionWithResponse(body, cart);
}
@Get("session/:sessionId")
@ -84,16 +74,7 @@ export class CheckoutController {
type: CheckoutSessionResponseDto,
})
async getSession(@Param() params: CheckoutSessionIdParamDto) {
const session = await this.checkoutSessions.getSession(params.sessionId);
return {
sessionId: params.sessionId,
expiresAt: session.expiresAt,
orderType: session.request.orderType,
cart: {
items: session.cart.items,
totals: session.cart.totals,
},
};
return this.checkoutSessions.getSessionResponse(params.sessionId);
}
@Post("validate")

View File

@ -5,6 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service.js";
import type {
CheckoutBuildCartRequest,
CheckoutCart,
CheckoutSessionResponse,
CreateOrderRequest,
OrderCreateResponse,
} from "@customer-portal/domain/orders";
@ -62,6 +63,43 @@ export class CheckoutSessionService {
return record;
}
/**
* Create a session and return the full response with cart summary.
*/
async createSessionWithResponse(
request: CheckoutBuildCartRequest,
cart: CheckoutCart
): Promise<CheckoutSessionResponse> {
const session = await this.createSession(request, cart);
return {
sessionId: session.sessionId,
expiresAt: session.expiresAt,
orderType: request.orderType,
cart: {
items: cart.items,
totals: cart.totals,
},
};
}
/**
* Get a session and return the full response with cart summary.
*/
async getSessionResponse(sessionId: string): Promise<CheckoutSessionResponse> {
const session = await this.getSession(sessionId);
return {
sessionId,
expiresAt: session.expiresAt,
orderType: session.request.orderType,
cart: {
items: session.cart.items,
totals: session.cart.totals,
},
};
}
async deleteSession(sessionId: string): Promise<void> {
const key = this.buildKey(sessionId);
await this.cache.del(key);

View File

@ -3,6 +3,45 @@ import { Logger } from "nestjs-pino";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import type { ContactIdentityData } from "./sim-fulfillment.service.js";
/**
* Configuration fields extracted from checkout payload and Salesforce order records.
* Used during fulfillment for SIM activation, MNP porting, and address data.
*/
export interface FulfillmentConfigurations {
simType?: string;
eid?: string;
activationType?: string;
scheduledAt?: string;
accessMode?: string;
// MNP porting fields
isMnp?: string;
mnpNumber?: string;
mnpExpiry?: string;
mnpPhone?: string;
mvnoAccountNumber?: string;
portingFirstName?: string;
portingLastName?: string;
portingFirstNameKatakana?: string;
portingLastNameKatakana?: string;
portingGender?: string;
portingDateOfBirth?: string;
// Nested MNP object (alternative format)
mnp?: Record<string, unknown>;
// Address override from checkout
address?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Top-level payload passed into the fulfillment pipeline.
* Contains the order type and checkout configurations.
*/
export interface FulfillmentPayload {
orderType?: string;
configurations?: unknown;
[key: string]: unknown;
}
/**
* Fulfillment Context Mapper Service
*
@ -22,8 +61,8 @@ export class FulfillmentContextMapper {
extractConfigurations(
rawConfigurations: unknown,
sfOrder?: SalesforceOrderRecord | null
): Record<string, unknown> {
const config: Record<string, unknown> = {};
): FulfillmentConfigurations {
const config: FulfillmentConfigurations = {};
// Start with payload configurations if provided
if (rawConfigurations && typeof rawConfigurations === "object") {
@ -49,7 +88,7 @@ export class FulfillmentContextMapper {
}
// MNP fields
if (!config["isMnp"] && sfOrder.MNP_Application__c) {
config["isMnp"] = sfOrder.MNP_Application__c ? "true" : undefined;
config["isMnp"] = "true";
}
if (!config["mnpNumber"] && sfOrder.MNP_Reservation_Number__c) {
config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c;

View File

@ -6,7 +6,10 @@ import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import { SimFulfillmentService, type SimFulfillmentResult } from "./sim-fulfillment.service.js";
import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js";
import { FulfillmentContextMapper } from "./fulfillment-context-mapper.service.js";
import {
FulfillmentContextMapper,
type FulfillmentPayload,
} from "./fulfillment-context-mapper.service.js";
import type { OrderFulfillmentContext } from "./order-fulfillment-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
@ -68,7 +71,7 @@ export class FulfillmentStepExecutors {
*/
async executeSimFulfillment(
ctx: OrderFulfillmentContext,
payload: Record<string, unknown>
payload: FulfillmentPayload
): Promise<SimFulfillmentResult> {
if (ctx.orderDetails?.orderType !== "SIM") {
return { activated: false, simType: "eSIM" as const };
@ -183,7 +186,7 @@ export class FulfillmentStepExecutors {
simFulfillmentResult?: SimFulfillmentResult
): WhmcsOrderItemMappingResult {
if (!ctx.orderDetails) {
throw new Error("Order details are required for mapping");
throw new FulfillmentException("Order details are required for mapping");
}
// Use domain mapper directly - single transformation!

View File

@ -9,6 +9,8 @@ import type {
} from "./order-fulfillment-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
import { FulfillmentException } from "@bff/core/exceptions/domain-exceptions.js";
import type { FulfillmentPayload } from "./fulfillment-context-mapper.service.js";
type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
@ -45,10 +47,7 @@ export class FulfillmentStepFactory {
* 8. sf_registration_complete
* 9. opportunity_update
*/
buildSteps(
context: OrderFulfillmentContext,
payload: Record<string, unknown>
): DistributedStep[] {
buildSteps(context: OrderFulfillmentContext, payload: FulfillmentPayload): DistributedStep[] {
// Mutable state container for cross-step data
const state: StepState = {};
@ -106,7 +105,7 @@ export class FulfillmentStepFactory {
private createSimFulfillmentStep(
ctx: OrderFulfillmentContext,
payload: Record<string, unknown>,
payload: FulfillmentPayload,
state: StepState
): DistributedStep {
return {
@ -163,7 +162,7 @@ export class FulfillmentStepFactory {
description: "Create order in WHMCS",
execute: this.createTrackedStep(ctx, "whmcs_create", async () => {
if (!state.mappingResult) {
throw new Error("Mapping result is not available");
throw new FulfillmentException("Mapping result is not available");
}
const result = await this.executors.executeWhmcsCreate(ctx, state.mappingResult);
// eslint-disable-next-line require-atomic-updates -- Sequential step execution, state is scoped to this transaction
@ -184,7 +183,7 @@ export class FulfillmentStepFactory {
description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(ctx, "whmcs_accept", async () => {
if (!state.whmcsCreateResult) {
throw new Error("WHMCS create result is not available");
throw new FulfillmentException("WHMCS create result is not available");
}
const acceptResult = await this.executors.executeWhmcsAccept(ctx, state.whmcsCreateResult);
// Update state with serviceIds from accept (services are created on accept, not on add)

View File

@ -4,7 +4,15 @@ import type { OrderBusinessValidation, UserMapping } from "@customer-portal/doma
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { SalesforceOrderFieldMapService } from "@bff/integrations/salesforce/config/order-field-map.service.js";
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
/**
* Salesforce Order record payload.
* Keys are dynamic (resolved from SalesforceOrderFieldMap at runtime),
* so a static interface is not possible. Known static keys include
* AccountId, EffectiveDate, Status, Pricebook2Id, and OpportunityId.
*/
type SalesforceOrderFields = Record<string, unknown>;
function assignIfString(target: SalesforceOrderFields, key: string, value: unknown): void {
if (typeof value === "string" && value.trim().length > 0) {
target[key] = value;
}
@ -26,11 +34,11 @@ export class OrderBuilder {
userMapping: UserMapping,
pricebookId: string,
userId: string
): Promise<Record<string, unknown>> {
): Promise<SalesforceOrderFields> {
const today = new Date().toISOString().slice(0, 10);
const orderFieldNames = this.orderFieldMap.fields.order;
const orderFields: Record<string, unknown> = {
const orderFields: SalesforceOrderFields = {
AccountId: userMapping.sfAccountId,
EffectiveDate: today,
Status: "Pending Review",
@ -59,7 +67,7 @@ export class OrderBuilder {
}
private addActivationFields(
orderFields: Record<string, unknown>,
orderFields: SalesforceOrderFields,
body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
): void {
@ -71,7 +79,7 @@ export class OrderBuilder {
}
private addInternetFields(
orderFields: Record<string, unknown>,
orderFields: SalesforceOrderFields,
body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
): void {
@ -80,7 +88,7 @@ export class OrderBuilder {
}
private addSimFields(
orderFields: Record<string, unknown>,
orderFields: SalesforceOrderFields,
body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
): void {
@ -116,7 +124,7 @@ export class OrderBuilder {
}
private async addAddressSnapshot(
orderFields: Record<string, unknown>,
orderFields: SalesforceOrderFields,
userId: string,
body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]

View File

@ -8,6 +8,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.
import { FulfillmentStepFactory } from "./fulfillment-step-factory.service.js";
import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js";
import type { SimFulfillmentResult } from "./sim-fulfillment.service.js";
import type { FulfillmentPayload } from "./fulfillment-context-mapper.service.js";
import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { OrderDetails } from "@customer-portal/domain/orders";
@ -65,7 +66,7 @@ export class OrderFulfillmentOrchestrator {
*/
async executeFulfillment(
sfOrderId: string,
payload: Record<string, unknown>,
payload: FulfillmentPayload,
idempotencyKey: string
): Promise<OrderFulfillmentContext> {
const context = this.initializeContext(sfOrderId, idempotencyKey, payload);
@ -198,7 +199,7 @@ export class OrderFulfillmentOrchestrator {
private initializeContext(
sfOrderId: string,
idempotencyKey: string,
payload: Record<string, unknown>
payload: FulfillmentPayload
): OrderFulfillmentContext {
const orderType = typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown";
return {

View File

@ -1,4 +1,5 @@
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { OrderCreateResponse } from "@customer-portal/domain/orders";
@ -23,13 +24,17 @@ import type { OrderCreateResponse } from "@customer-portal/domain/orders";
export class OrderIdempotencyService {
private readonly RESULT_PREFIX = "order-result:";
private readonly LOCK_PREFIX = "order-lock:";
private readonly RESULT_TTL_SECONDS = 86400; // 24 hours
private readonly LOCK_TTL_SECONDS = 60; // 60 seconds for processing
private readonly RESULT_TTL_SECONDS: number;
private readonly LOCK_TTL_SECONDS: number;
constructor(
private readonly cache: CacheService,
@Inject(Logger) private readonly logger: Logger
) {}
@Inject(Logger) private readonly logger: Logger,
config: ConfigService
) {
this.RESULT_TTL_SECONDS = config.get<number>("ORDER_RESULT_TTL_SECONDS", 86400); // 24 hours
this.LOCK_TTL_SECONDS = config.get<number>("ORDER_LOCK_TTL_SECONDS", 60); // 60 seconds for processing
}
/**
* Check if an order was already created for this checkout session

View File

@ -2,6 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
import { Logger } from "nestjs-pino";
import { OrderPricebookService } from "./order-pricebook.service.js";
import { createOrderRequestSchema } from "@customer-portal/domain/orders";
import { OrderValidationException } from "@bff/core/exceptions/domain-exceptions.js";
/**
* Handles building order items from SKU data
@ -51,7 +52,9 @@ export class OrderItemBuilder {
{ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId },
"PricebookEntry missing UnitPrice"
);
throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`);
throw new OrderValidationException(
`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`
);
}
payload.push({

View File

@ -9,6 +9,7 @@ import {
SimActivationException,
OrderValidationException,
} from "@bff/core/exceptions/domain-exceptions.js";
import type { FulfillmentConfigurations } from "./fulfillment-context-mapper.service.js";
/**
* Contact identity data for PA05-05 voice option registration
@ -36,7 +37,7 @@ export interface SimAssignmentDetails {
export interface SimFulfillmentRequest {
orderDetails: OrderDetails;
configurations: Record<string, unknown>;
configurations: FulfillmentConfigurations;
/** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */
assignedPhysicalSimId?: string;
/** Voice Mail enabled from Order.SIM_Voice_Mail__c */
@ -548,10 +549,10 @@ export class SimFulfillmentService {
return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
}
private extractMnpConfig(config: Record<string, unknown>) {
private extractMnpConfig(config: FulfillmentConfigurations) {
const nested = config["mnp"];
const hasNestedMnp = nested && typeof nested === "object";
const source = hasNestedMnp ? (nested as Record<string, unknown>) : config;
const source = hasNestedMnp ? nested : config;
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") {

View File

@ -1,4 +1,5 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
/**
* Simple per-instance SSE connection limiter.
@ -11,9 +12,13 @@ import { Injectable } from "@nestjs/common";
*/
@Injectable()
export class RealtimeConnectionLimiterService {
private readonly maxPerUser = 3;
private readonly maxPerUser: number;
private readonly counts = new Map<string, number>();
constructor(config: ConfigService) {
this.maxPerUser = config.get<number>("REALTIME_MAX_CONNECTIONS_PER_USER", 3);
}
tryAcquire(userId: string): boolean {
const current = this.counts.get(userId) ?? 0;
if (current >= this.maxPerUser) {

View File

@ -1,4 +1,9 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import {
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
@ -88,7 +93,7 @@ export class InternetEligibilityService {
): Promise<string> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
throw new Error("No Salesforce mapping found for current user");
throw new BadRequestException("No Salesforce mapping found for current user");
}
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
@ -151,7 +156,9 @@ export class InternetEligibilityService {
sfAccountId,
error: extractErrorMessage(error),
});
throw new Error("Failed to request availability check. Please try again later.");
throw new InternalServerErrorException(
"Failed to request availability check. Please try again later."
);
}
}
@ -269,7 +276,7 @@ export class InternetEligibilityService {
);
const update = this.sf.sobject("Account")?.update;
if (!update) {
throw new Error("Salesforce Account update method not available");
throw new InternalServerErrorException("Salesforce Account update method not available");
}
const basePayload: { Id: string } & Record<string, unknown> = {

View File

@ -18,6 +18,7 @@ import { SalesforceCaseService } from "@bff/integrations/salesforce/services/sal
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type {
OrderPlacedCaseParams,
EligibilityCheckCaseParams,
@ -296,7 +297,7 @@ export class WorkflowCaseManager {
filename,
error: extractErrorMessage(error),
});
throw new Error("Failed to create verification case");
throw new SalesforceOperationException("Failed to create verification case");
}
}

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type {
CancellationPreview,
CancellationStatus,
@ -28,15 +29,14 @@ function isValidPortalStage(stage: string): stage is PortalStage {
@Injectable()
export class CancellationService {
private readonly logger = new Logger(CancellationService.name);
// eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor
constructor(
private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator,
private readonly opportunityService: SalesforceOpportunityService,
private readonly internetCancellation: InternetCancellationService,
private readonly simCancellation: SimCancellationService,
private readonly validationCoordinator: SubscriptionValidationCoordinator
private readonly validationCoordinator: SubscriptionValidationCoordinator,
@Inject(Logger) private readonly logger: Logger
) {}
/**

View File

@ -1,5 +1,15 @@
// Notification types for SIM management (BFF-specific, not domain types)
export type SimNotificationContext = Record<string, unknown>;
/**
* Notification context for SIM management actions (BFF-specific, not domain types).
* Contains arbitrary action metadata (account, subscriptionId, error messages, etc.)
* that varies per action type.
*/
export interface SimNotificationContext {
account?: string;
subscriptionId?: number;
userId?: string;
error?: string;
[key: string]: unknown;
}
export interface SimActionNotification {
action: string;
@ -10,3 +20,19 @@ export interface SimActionNotification {
export interface SimValidationResult {
account: string;
}
/** Debug output for SIM subscription troubleshooting (admin-only endpoint) */
export interface SimDebugInfo {
subscriptionId: number;
productName: string;
domain?: string | undefined;
orderNumber?: string | undefined;
isSimService: boolean;
groupName?: string | undefined;
status: string;
extractedAccount: string | null;
accountSource: string;
customFieldKeys: string[];
customFields?: Record<string, string> | null | undefined;
hint?: string | undefined;
}

View File

@ -5,6 +5,7 @@ import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SimValidationService } from "../sim-validation.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
import { SimBillingService } from "../queries/sim-billing.service.js";
import { SimNotificationService } from "../support/sim-notification.service.js";
@ -272,7 +273,7 @@ export class SimTopUpService {
// to ensure consistency across all failure scenarios.
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
throw new Error(
throw new FreebitOperationException(
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
);
}

View File

@ -8,6 +8,7 @@ import { SimCancellationService } from "./mutations/sim-cancellation.service.js"
import { EsimManagementService } from "./mutations/esim-management.service.js";
import { SimValidationService } from "./sim-validation.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimDebugInfo } from "../interfaces/sim-base.interface.js";
import { simInfoSchema } from "@customer-portal/domain/sim";
import type {
SimInfo,
@ -182,10 +183,7 @@ export class SimOrchestrator {
/**
* Debug method to check subscription data for SIM services
*/
async debugSimSubscription(
userId: string,
subscriptionId: number
): Promise<Record<string, unknown>> {
async debugSimSubscription(userId: string, subscriptionId: number): Promise<SimDebugInfo> {
return this.simValidation.debugSimSubscription(userId, subscriptionId);
}

View File

@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
import type { SimValidationResult, SimDebugInfo } from "../interfaces/sim-base.interface.js";
import {
cleanSimAccount,
extractSimAccountFromSubscription,
@ -83,10 +83,7 @@ export class SimValidationService {
/**
* Debug method to check subscription data for SIM services
*/
async debugSimSubscription(
userId: string,
subscriptionId: number
): Promise<Record<string, unknown>> {
async debugSimSubscription(userId: string, subscriptionId: number): Promise<SimDebugInfo> {
try {
const subscription = await this.subscriptionsService.getSubscriptionById(
userId,

View File

@ -9,15 +9,20 @@ const ADMIN_EMAIL = "info@asolutions.co.jp";
const SIM_OPERATION_FAILURE_MESSAGE = "SIM operation failed. Please try again or contact support.";
/**
* API call log structure for notification emails
* API call log structure for notification emails.
* Payloads are serialized to JSON in email bodies via JSON.stringify.
*/
export interface ApiCallLog {
url: string;
senddata?: Record<string, unknown> | string;
json?: Record<string, unknown> | string;
result: Record<string, unknown> | string;
senddata?: JsonObject | string;
json?: JsonObject | string;
result: JsonObject | string;
}
/** JSON-serializable object (used in API call logs for email notifications) */
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
type JsonObject = { [key: string]: JsonValue | undefined };
/**
* Unified SIM notification service.
* Handles all SIM-related email notifications including:
@ -326,8 +331,8 @@ Comments: ${params.comments || "N/A"}`;
/**
* Redact sensitive information from notification context
*/
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
private redactSensitiveFields(context: SimNotificationContext): SimNotificationContext {
const sanitized: SimNotificationContext = {};
for (const [key, value] of Object.entries(context)) {
if (typeof key === "string" && key.toLowerCase().includes("password")) {
sanitized[key] = "[REDACTED]";

View File

@ -15,6 +15,7 @@ import { SimPlanService } from "./services/mutations/sim-plan.service.js";
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
import { EsimManagementService } from "./services/mutations/esim-management.service.js";
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
import type { SimDebugInfo } from "./interfaces/sim-base.interface.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
@ -126,7 +127,7 @@ export class SimController {
async debugSimSubscription(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto
): Promise<Record<string, unknown>> {
): Promise<SimDebugInfo> {
return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
}

View File

@ -140,7 +140,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
const activities: Activity[] = [];
for (const invoice of invoices) {
const baseMetadata: Record<string, unknown> = {
const baseMetadata: Record<string, string | number | boolean | null> = {
amount: invoice.total,
currency: invoice.currency ?? DEFAULT_CURRENCY,
};
@ -179,7 +179,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] {
return subscriptions.map(subscription => {
const metadata: Record<string, unknown> = {
const metadata: Record<string, string | number | boolean | null> = {
productName: subscription.productName,
status: subscription.status,
};

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { Logger } from "nestjs-pino";
@ -100,7 +100,9 @@ export class AddressReconcileQueueService {
error: errorMessage,
});
throw new Error(`Failed to queue address reconciliation: ${errorMessage}`);
throw new InternalServerErrorException(
`Failed to queue address reconciliation: ${errorMessage}`
);
}
}

View File

@ -14,7 +14,8 @@ import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { ResidenceCardService } from "./residence-card.service.js";
import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
const MAX_FILE_BYTES = Number(process.env["UPLOAD_MAX_FILE_BYTES"]) || DEFAULT_MAX_FILE_BYTES;
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]);
type UploadedResidenceCard = {

View File

@ -160,7 +160,7 @@ const whmcsInvoiceCommonSchema = z
companyname: s.optional(),
currencycode: s.optional(),
})
.passthrough();
.strip();
export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({
id: numberLike,
@ -290,6 +290,8 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
export const whmcsCurrenciesResponseSchema = z
.object({
result: z.enum(["success", "error"]).optional(),
message: z.string().optional(),
errorcode: z.string().optional(),
totalresults: z
.string()
.transform(val => Number.parseInt(val, 10))
@ -300,8 +302,7 @@ export const whmcsCurrenciesResponseSchema = z
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema),
})
.optional(),
// Allow any additional flat currency keys for flat format
})
.catchall(z.string().or(z.number()));
.strip();
export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>;

View File

@ -41,7 +41,10 @@ export const cartItemSchema = z.object({
planSku: z.string().min(1, "Plan SKU is required"),
planName: z.string().min(1, "Plan name is required"),
addonSkus: z.array(z.string()).default([]),
configuration: z.record(z.string(), z.unknown()).default({}),
// Checkout configuration values are user-supplied key-value pairs (strings, numbers, booleans)
configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.default({}),
pricing: z.object({
monthlyTotal: z.number().nonnegative(),
oneTimeTotal: z.number().nonnegative(),

View File

@ -675,6 +675,7 @@ export const apiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
// Intentionally z.unknown() values — error details vary by error type and may contain nested objects
details: z.record(z.string(), z.unknown()).optional(),
}),
});

View File

@ -13,6 +13,8 @@ import { z } from "zod";
/**
* Base schema for Salesforce SOQL query result
*/
// Base schema uses z.unknown() for records because it is always overridden by
// salesforceResponseSchema() which extends records with the caller's typed schema.
const salesforceResponseBaseSchema = z.object({
totalSize: z.number(),
done: z.boolean(),

View File

@ -26,3 +26,31 @@ export const whmcsNumberLike = z.union([z.number(), z.string()]);
* Use for WHMCS boolean flags that arrive in varying formats.
*/
export const whmcsBooleanLike = z.union([z.boolean(), z.number(), z.string()]);
/**
* Coercing required number accepts number or numeric string, always outputs number.
* Use for WHMCS fields that must be a number but may arrive as a string.
*/
export const whmcsRequiredNumber = z.preprocess(value => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
return value;
}, z.number());
/**
* Coercing optional number accepts number, numeric string, null, undefined, or empty string.
* Returns undefined for missing/empty values.
* Use for WHMCS fields that are optional numbers but may arrive as strings.
*/
export const whmcsOptionalNumber = z.preprocess((value): number | undefined => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}, z.number().optional());

View File

@ -46,8 +46,7 @@ export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"])
export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]);
export const categoryEnum = z.enum(["technical", "billing", "account", "general"]);
export const billingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Onetime", "Free"]);
export const subscriptionBillingCycleEnum = z.enum([
export const billingCycleSchema = z.enum([
"Monthly",
"Quarterly",
"Semi-Annually",
@ -57,6 +56,7 @@ export const subscriptionBillingCycleEnum = z.enum([
"One-time",
"Free",
]);
export type BillingCycle = z.infer<typeof billingCycleSchema>;
// ============================================================================
// Salesforce and SOQL Validation Schemas
@ -123,6 +123,7 @@ export const apiErrorResponseSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
// Intentionally z.unknown() — error details vary by error type
details: z.unknown().optional(),
}),
});

View File

@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined {
country: client.country ?? null,
countryCode: client.countrycode ?? null,
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null,
phoneCountryCode: client.phonecc == null ? null : String(client.phonecc),
phoneCountryCode: client.phonecc ?? null,
});
const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");

View File

@ -67,7 +67,7 @@ export const whmcsCustomFieldSchema = z
name: z.string().optional(),
type: z.string().optional(),
})
.passthrough();
.strip();
export const whmcsUserSchema = z
.object({
@ -76,7 +76,7 @@ export const whmcsUserSchema = z
email: z.string(),
is_owner: booleanLike.optional(),
})
.passthrough();
.strip();
export const whmcsEmailPreferencesSchema = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
@ -89,13 +89,13 @@ const customFieldsSchema = z
.object({
customfield: z.union([whmcsCustomFieldSchema, z.array(whmcsCustomFieldSchema)]),
})
.passthrough(),
.strip(),
])
.optional();
const usersSchema = z
.object({ user: z.union([whmcsUserSchema, z.array(whmcsUserSchema)]) })
.passthrough()
.strip()
.optional();
export const whmcsClientSchema = z
@ -142,7 +142,7 @@ export const whmcsClientSchema = z
customfields: customFieldsSchema,
users: usersSchema,
})
.catchall(z.unknown());
.strip();
export const whmcsClientStatsSchema = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
@ -155,7 +155,7 @@ export const whmcsClientResponseSchema = z
client: whmcsClientSchema,
stats: whmcsClientStatsSchema,
})
.catchall(z.unknown());
.strip();
export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>;
export type WhmcsUser = z.infer<typeof whmcsUserSchema>;

View File

@ -13,6 +13,10 @@
import { z } from "zod";
import { countryCodeSchema } from "../common/schema.js";
import {
whmcsNumberLike as numberLike,
whmcsBooleanLike as booleanLike,
} from "../common/providers/whmcs-utils/index.js";
import {
whmcsClientSchema as whmcsRawClientSchema,
whmcsCustomFieldSchema,
@ -23,8 +27,6 @@ import {
// ============================================================================
const stringOrNull = z.union([z.string(), z.null()]);
const booleanLike = z.union([z.boolean(), z.number(), z.string()]);
const numberLike = z.union([z.number(), z.string()]);
/**
* Normalize boolean-like values to actual booleans
@ -172,30 +174,33 @@ const subUserSchema = z
*/
const statsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional();
const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema);
const normalizeCustomFields = (input: unknown): unknown => {
if (!input) return input;
if (Array.isArray(input)) return input;
if (typeof input === "object" && input !== null && "customfield" in input) {
const cf = (input as Record<string, unknown>)["customfield"];
if (Array.isArray(cf)) return cf;
return cf ? [cf] : input;
}
return input;
};
const whmcsCustomFieldsSchema = z
.union([
z.record(z.string(), z.string()),
whmcsRawCustomFieldsArraySchema,
z
.object({
customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(),
})
.passthrough(),
])
.preprocess(normalizeCustomFields, z.array(whmcsCustomFieldSchema).optional())
.optional();
const whmcsUsersSchema = z
.union([
z.array(subUserSchema),
z
.object({
user: z.union([subUserSchema, z.array(subUserSchema)]).optional(),
})
.passthrough(),
])
.optional();
const normalizeUsers = (input: unknown): unknown => {
if (!input) return input;
if (Array.isArray(input)) return input;
if (typeof input === "object" && input !== null && "user" in input) {
const u = (input as Record<string, unknown>)["user"];
if (Array.isArray(u)) return u;
return u ? [u] : input;
}
return input;
};
const whmcsUsersSchema = z.preprocess(normalizeUsers, z.array(subUserSchema).optional()).optional();
/**
* WhmcsClient - Full WHMCS client data

View File

@ -18,7 +18,10 @@ export const activitySchema = z.object({
description: z.string().optional(),
date: z.string(),
relatedId: z.number().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
// Activity metadata varies by activity type (invoice, service, etc.)
metadata: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
});
export const invoiceActivityMetadataSchema = z
@ -68,7 +71,10 @@ export const dashboardSummarySchema = z.object({
export const dashboardErrorSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
// Error details vary by error type; values are primitive scalars
details: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
});
export const activityFilterSchema = z.enum(["all", "billing", "orders", "support"]);

View File

@ -18,8 +18,32 @@ export const salesforceOrderItemRecordSchema = z.object({
UnitPrice: z.number().nullable().optional(),
TotalPrice: z.number().nullable().optional(),
PricebookEntryId: z.string().nullable().optional(),
// Note: PricebookEntry nested object comes from catalog domain
PricebookEntry: z.unknown().nullable().optional(),
// Minimal PricebookEntry shape for fields used by the order mapper.
// Full schema lives in services/providers/salesforce/raw.types.ts.
PricebookEntry: z
.object({
Id: z.string(),
Product2Id: z.string().nullable().optional(),
Product2: z
.object({
Id: z.string(),
Name: z.string().optional(),
StockKeepingUnit: z.string().optional(),
Item_Class__c: z.string().nullable().optional(),
Billing_Cycle__c: z.string().nullable().optional(),
Internet_Offering_Type__c: z.string().nullable().optional(),
Internet_Plan_Tier__c: z.string().nullable().optional(),
VPN_Region__c: z.string().nullable().optional(),
Bundled_Addon__c: z.string().nullable().optional(),
Is_Bundled_Addon__c: z.boolean().nullable().optional(),
WH_Product_ID__c: z.number().nullable().optional(),
WH_Product_Name__c: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
Billing_Cycle__c: z.string().nullable().optional(),
WHMCS_Service_ID__c: z.string().nullable().optional(),
CreatedDate: z.string().optional(),
@ -114,7 +138,7 @@ export const salesforceOrderProvisionEventPayloadSchema = z
OrderId__c: z.string().optional(),
OrderId: z.string().optional(),
})
.passthrough();
.strip();
export type SalesforceOrderProvisionEventPayload = z.infer<
typeof salesforceOrderProvisionEventPayloadSchema
@ -128,7 +152,7 @@ export const salesforceOrderProvisionEventSchema = z
payload: salesforceOrderProvisionEventPayloadSchema,
replayId: z.number().optional(),
})
.passthrough();
.strip();
export type SalesforceOrderProvisionEvent = z.infer<typeof salesforceOrderProvisionEventSchema>;
@ -152,7 +176,7 @@ export const salesforcePubSubErrorMetadataSchema = z
.object({
"error-code": z.array(z.string()).optional(),
})
.passthrough();
.strip();
export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>;
@ -164,7 +188,7 @@ export const salesforcePubSubErrorSchema = z
details: z.string().optional(),
metadata: salesforcePubSubErrorMetadataSchema.optional(),
})
.passthrough();
.strip();
export type SalesforcePubSubError = z.infer<typeof salesforcePubSubErrorSchema>;
@ -203,6 +227,7 @@ export const salesforcePubSubCallbackSchema = z.object({
data: z.union([
salesforceOrderProvisionEventSchema,
salesforcePubSubErrorSchema,
// Fallback for unknown Pub/Sub event types whose shape cannot be predicted
z.record(z.string(), z.unknown()),
z.null(),
]),

View File

@ -187,7 +187,7 @@ export const orderSelectionsSchema = z
})
.optional(),
})
.passthrough();
.strip();
export type OrderSelections = z.infer<typeof orderSelectionsSchema>;

View File

@ -48,7 +48,9 @@ export const whmcsPaymentGatewayRawSchema = z.object({
display_name: z.string().optional(),
type: z.string(),
visible: z.union([z.boolean(), z.number(), z.string()]).optional(),
configuration: z.record(z.string(), z.unknown()).optional(),
configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
});
export type WhmcsPaymentGatewayRaw = z.infer<typeof whmcsPaymentGatewayRawSchema>;

View File

@ -47,7 +47,10 @@ export const paymentGatewaySchema = z.object({
displayName: z.string(),
type: paymentGatewayTypeSchema,
isActive: z.boolean(),
configuration: z.record(z.string(), z.unknown()).optional(),
// Gateway configuration varies by provider; values are primitive scalars
configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
});
export const paymentGatewayListSchema = z.object({

View File

@ -43,7 +43,7 @@ export interface SalesforceProductFieldMap {
export interface PricingTier {
name: string;
price: number;
billingCycle: "Monthly" | "Onetime" | "Annual";
billingCycle: "Monthly" | "One-time" | "Annually";
description?: string;
features?: string[];
isRecommended?: boolean;

View File

@ -11,27 +11,10 @@ import { z } from "zod";
import {
whmcsString as s,
whmcsNumberLike as numberLike,
whmcsRequiredNumber as normalizeRequiredNumber,
whmcsOptionalNumber as normalizeOptionalNumber,
} from "../../../common/providers/whmcs-utils/index.js";
const normalizeRequiredNumber = z.preprocess(value => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
return value;
}, z.number());
const normalizeOptionalNumber = z.preprocess((value): number | undefined => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}, z.number().optional());
const optionalStringField = () =>
z
.union([z.string(), z.number()])

View File

@ -6,6 +6,8 @@
import { z } from "zod";
import { billingCycleSchema } from "../common/schema.js";
// Subscription Status Schema
export const subscriptionStatusSchema = z.enum([
"Active",
@ -17,17 +19,8 @@ export const subscriptionStatusSchema = z.enum([
"Completed",
]);
// Subscription Cycle Schema
export const subscriptionCycleSchema = z.enum([
"Monthly",
"Quarterly",
"Semi-Annually",
"Annually",
"Biennially",
"Triennially",
"One-time",
"Free",
]);
// Subscription Cycle Schema — re-exported from common
export const subscriptionCycleSchema = billingCycleSchema;
// Subscription Schema
export const subscriptionSchema = z.object({
@ -105,7 +98,8 @@ export const subscriptionStatsSchema = z.object({
*/
export const simActionResponseSchema = z.object({
message: z.string(),
data: z.unknown().optional(),
/** Action-specific payload — varies by SIM/internet operation (top-up, cancellation, etc.) */
data: z.record(z.string(), z.unknown()).optional(),
});
/**