Update TypeScript configuration to include scripts directory, clean up unused lines in various files, and refactor error handling in cache service. Enhance logging for database and Redis connections, and streamline order processing logic in the orders module. Improve validation schemas and ensure consistent import/export practices across modules.

This commit is contained in:
barsa 2025-09-25 11:44:10 +09:00
parent 6becad1511
commit e66e7a5884
92 changed files with 1751 additions and 1653 deletions

View File

@ -22,5 +22,3 @@ async function generate() {
}
void generate();

View File

@ -6,6 +6,17 @@ import { Logger } from "nestjs-pino";
import helmet from "helmet";
import cookieParser from "cookie-parser";
import * as express from "express";
import type { CookieOptions, Response, NextFunction } from "express";
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace Express {
interface Response {
setSecureCookie: (name: string, value: string, options?: CookieOptions) => void;
}
}
}
/* eslint-enable @typescript-eslint/no-namespace */
import { GlobalExceptionFilter } from "@bff/core/http/http-exception.filter";
import { AuthErrorFilter } from "@bff/core/http/auth-error.filter";
@ -60,21 +71,18 @@ export async function bootstrap(): Promise<INestApplication> {
// Enhanced cookie parser with security options
app.use(cookieParser());
// Configure session cookies with security options
app.use((req: any, res: any, next: any) => {
// Set secure cookie defaults
res.cookie = ((originalCookie) => {
return function(name: string, value: string, options: any = {}) {
const secureOptions = {
httpOnly: true,
secure: configService.get("NODE_ENV") === "production",
sameSite: "strict" as const,
...options,
};
return originalCookie.call(res, name, value, secureOptions);
};
})(res.cookie.bind(res));
// Provide helper for secure cookie handling without mutating Express response methods
const secureCookieDefaults: CookieOptions = {
httpOnly: true,
sameSite: "strict",
secure: configService.get("NODE_ENV") === "production",
};
app.use((_req, res: Response, next: NextFunction) => {
res.setSecureCookie = (name: string, value: string, options: CookieOptions = {}) => {
res.cookie(name, value, { ...secureCookieDefaults, ...options });
};
next();
});
@ -160,11 +168,13 @@ export async function bootstrap(): Promise<INestApplication> {
// Enhanced startup information
logger.log(`🚀 BFF API running on: http://localhost:${port}/api`);
logger.log(`🌐 Frontend Portal: http://localhost:${configService.get("NEXT_PORT", 3000)}`);
logger.log(
`🗄️ Database: ${configService.get("DATABASE_URL", "postgresql://dev:dev@localhost:5432/portal_dev")}`
);
if (configService.get("DATABASE_URL")) {
logger.log("🗄️ Database connection configured");
}
logger.log(`🔗 Prisma Studio: http://localhost:5555`);
logger.log(`🔴 Redis: ${configService.get("REDIS_URL", "redis://localhost:6379")}`);
if (configService.get("REDIS_URL")) {
logger.log("🔴 Redis connection configured");
}
if (configService.get("NODE_ENV") !== "production") {
logger.log(`📚 API Documentation: http://localhost:${port}/docs`);

View File

@ -1,4 +1,4 @@
import { ConfigModuleOptions } from "@nestjs/config";
import type { ConfigModuleOptions } from "@nestjs/config";
import { validate } from "./env.validation";
export const appConfig: ConfigModuleOptions = {
@ -6,5 +6,3 @@ export const appConfig: ConfigModuleOptions = {
expandVariables: true,
validate,
};

View File

@ -82,5 +82,3 @@ export function validate(config: Record<string, unknown>): Record<string, unknow
}
return result.data;
}

View File

@ -203,4 +203,3 @@ export function getOrderItemProduct2Select(additional: string[] = []): string {
const all = [...base, ...additional];
return all.map(f => `PricebookEntry.Product2.${f}`).join(", ");
}

View File

@ -21,5 +21,3 @@ export const apiRoutes: Routes = [
],
},
];

View File

@ -12,5 +12,3 @@ export const createThrottlerConfig = (configService: ConfigService): ThrottlerMo
limit: configService.get<number>("AUTH_RATE_LIMIT_LIMIT", 3),
},
];

View File

@ -7,9 +7,9 @@ import {
BadRequestException,
ConflictException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { Logger } from 'nestjs-pino';
} from "@nestjs/common";
import { Response } from "express";
import { Logger } from "nestjs-pino";
interface StandardErrorResponse {
success: false;
@ -26,24 +26,27 @@ interface StandardErrorResponse {
export class AuthErrorFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(exception: UnauthorizedException | ForbiddenException | BadRequestException | ConflictException, host: ArgumentsHost) {
catch(
exception: UnauthorizedException | ForbiddenException | BadRequestException | ConflictException,
host: ArgumentsHost
) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const status = exception.getStatus();
const message = exception.message;
// Map specific auth errors to user-friendly messages
const userMessage = this.getUserFriendlyMessage(message, status);
const errorCode = this.getErrorCode(message, status);
// Log the error (without sensitive information)
this.logger.warn('Authentication error', {
this.logger.warn("Authentication error", {
path: request.url,
method: request.method,
errorCode,
userAgent: request.headers['user-agent'],
userAgent: request.headers["user-agent"],
ip: request.ip,
});
@ -63,82 +66,86 @@ export class AuthErrorFilter implements ExceptionFilter {
private getUserFriendlyMessage(message: string, status: number): string {
// Production-safe error messages that don't expose sensitive information
if (status === HttpStatus.UNAUTHORIZED) {
if (message.includes('Invalid credentials') || message.includes('Invalid email or password')) {
return 'Invalid email or password. Please try again.';
if (
message.includes("Invalid credentials") ||
message.includes("Invalid email or password")
) {
return "Invalid email or password. Please try again.";
}
if (message.includes('Token has been revoked') || message.includes('Invalid refresh token')) {
return 'Your session has expired. Please log in again.';
if (message.includes("Token has been revoked") || message.includes("Invalid refresh token")) {
return "Your session has expired. Please log in again.";
}
if (message.includes('Account is locked')) {
return 'Your account has been temporarily locked due to multiple failed login attempts. Please try again later.';
if (message.includes("Account is locked")) {
return "Your account has been temporarily locked due to multiple failed login attempts. Please try again later.";
}
if (message.includes('Unable to verify credentials')) {
return 'Unable to verify credentials. Please try again later.';
if (message.includes("Unable to verify credentials")) {
return "Unable to verify credentials. Please try again later.";
}
if (message.includes('Unable to verify account')) {
return 'Unable to verify account. Please try again later.';
if (message.includes("Unable to verify account")) {
return "Unable to verify account. Please try again later.";
}
return 'Authentication required. Please log in to continue.';
return "Authentication required. Please log in to continue.";
}
if (status === HttpStatus.FORBIDDEN) {
if (message.includes('Admin access required')) {
return 'You do not have permission to access this resource.';
if (message.includes("Admin access required")) {
return "You do not have permission to access this resource.";
}
return 'Access denied. You do not have permission to perform this action.';
return "Access denied. You do not have permission to perform this action.";
}
if (status === HttpStatus.BAD_REQUEST) {
if (message.includes('Salesforce account not found')) {
return 'Customer account not found. Please contact support.';
if (message.includes("Salesforce account not found")) {
return "Customer account not found. Please contact support.";
}
if (message.includes('Unable to verify customer information')) {
return 'Unable to verify customer information. Please contact support.';
if (message.includes("Unable to verify customer information")) {
return "Unable to verify customer information. Please contact support.";
}
return 'Invalid request. Please check your input and try again.';
return "Invalid request. Please check your input and try again.";
}
if (status === HttpStatus.CONFLICT) {
if (message.includes('already linked')) {
return 'This billing account is already linked. Please sign in.';
if (message.includes("already linked")) {
return "This billing account is already linked. Please sign in.";
}
if (message.includes('already exists')) {
return 'An account with this email already exists. Please sign in.';
if (message.includes("already exists")) {
return "An account with this email already exists. Please sign in.";
}
return 'Conflict detected. Please try again.';
return "Conflict detected. Please try again.";
}
return 'Authentication error. Please try again.';
return "Authentication error. Please try again.";
}
private getErrorCode(message: string, status: number): string {
if (status === HttpStatus.UNAUTHORIZED) {
if (message.includes('Invalid credentials') || message.includes('Invalid email or password')) return 'INVALID_CREDENTIALS';
if (message.includes('Token has been revoked')) return 'TOKEN_REVOKED';
if (message.includes('Invalid refresh token')) return 'INVALID_REFRESH_TOKEN';
if (message.includes('Account is locked')) return 'ACCOUNT_LOCKED';
if (message.includes('Unable to verify credentials')) return 'SERVICE_UNAVAILABLE';
if (message.includes('Unable to verify account')) return 'SERVICE_UNAVAILABLE';
return 'UNAUTHORIZED';
if (message.includes("Invalid credentials") || message.includes("Invalid email or password"))
return "INVALID_CREDENTIALS";
if (message.includes("Token has been revoked")) return "TOKEN_REVOKED";
if (message.includes("Invalid refresh token")) return "INVALID_REFRESH_TOKEN";
if (message.includes("Account is locked")) return "ACCOUNT_LOCKED";
if (message.includes("Unable to verify credentials")) return "SERVICE_UNAVAILABLE";
if (message.includes("Unable to verify account")) return "SERVICE_UNAVAILABLE";
return "UNAUTHORIZED";
}
if (status === HttpStatus.FORBIDDEN) {
if (message.includes('Admin access required')) return 'ADMIN_REQUIRED';
return 'FORBIDDEN';
if (message.includes("Admin access required")) return "ADMIN_REQUIRED";
return "FORBIDDEN";
}
if (status === HttpStatus.BAD_REQUEST) {
if (message.includes('Salesforce account not found')) return 'CUSTOMER_NOT_FOUND';
if (message.includes('Unable to verify customer information')) return 'SERVICE_UNAVAILABLE';
return 'INVALID_REQUEST';
if (message.includes("Salesforce account not found")) return "CUSTOMER_NOT_FOUND";
if (message.includes("Unable to verify customer information")) return "SERVICE_UNAVAILABLE";
return "INVALID_REQUEST";
}
if (status === HttpStatus.CONFLICT) {
if (message.includes('already linked')) return 'ACCOUNT_ALREADY_LINKED';
if (message.includes('already exists')) return 'ACCOUNT_EXISTS';
return 'CONFLICT';
if (message.includes("already linked")) return "ACCOUNT_ALREADY_LINKED";
if (message.includes("already exists")) return "ACCOUNT_EXISTS";
return "CONFLICT";
}
return 'AUTH_ERROR';
return "AUTH_ERROR";
}
}

View File

@ -81,5 +81,3 @@ export class GlobalExceptionFilter implements ExceptionFilter {
response.status(status).json(errorResponse);
}
}

View File

@ -1,3 +1 @@
export {}
export {};

View File

@ -67,5 +67,3 @@ import { LoggerModule } from "nestjs-pino";
exports: [LoggerModule],
})
export class LoggingModule {}

View File

@ -200,5 +200,3 @@ export async function safeAsync<T>(
return { data: fallback ?? null, error };
}
}

View File

@ -1,8 +1,11 @@
import { z } from 'zod';
import { z } from "zod";
import { BadRequestException } from "@nestjs/common";
// Simple Zod schemas for common validations
export const emailSchema = z.string().email().transform(email => email.toLowerCase().trim());
export const emailSchema = z
.string()
.email()
.transform(email => email.toLowerCase().trim());
export const uuidSchema = z.string().uuid();
export function normalizeAndValidateEmail(email: string): string {
@ -20,5 +23,3 @@ export function validateUuidV4OrThrow(id: string): string {
throw new Error("Invalid user ID format");
}
}

View File

@ -3,9 +3,9 @@
* Direct Zod validation without separate validation package
*/
import { ZodValidationPipe, createZodDto } from 'nestjs-zod';
import type { ZodSchema } from 'zod';
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodValidationPipe, createZodDto } from "nestjs-zod";
import type { ZodSchema } from "zod";
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from "@nestjs/common";
// Re-export the proper ZodPipe from nestjs-zod
export { ZodValidationPipe, createZodDto };
@ -23,7 +23,7 @@ export function ZodPipeClass(schema: ZodSchema) {
const result = schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Validation failed',
message: "Validation failed",
errors: result.error.issues,
});
}

View File

@ -7,5 +7,3 @@ import { AuditService } from "./audit.service";
exports: [AuditService],
})
export class AuditModule {}

View File

@ -191,4 +191,3 @@ export class AuditService {
};
}
}

View File

@ -7,5 +7,3 @@ import { CacheService } from "./cache.service";
exports: [CacheService],
})
export class CacheModule {}

View File

@ -11,7 +11,17 @@ export class CacheService {
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
return value ? (JSON.parse(value) as T) : null;
if (!value) {
return null;
}
try {
return JSON.parse(value) as T;
} catch (error) {
this.logger.warn({ key, error }, "Failed to parse cached value; evicting entry");
await this.redis.del(key);
return null;
}
}
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
@ -75,5 +85,3 @@ export class CacheService {
return fresh;
}
}

View File

@ -7,5 +7,3 @@ import { PrismaService } from "./prisma.service";
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -11,5 +11,3 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
await this.$disconnect();
}
}

View File

@ -12,5 +12,3 @@ import { EmailProcessor } from "./queue/email.processor";
exports: [EmailService, EmailQueueService],
})
export class EmailModule {}

View File

@ -40,5 +40,3 @@ export class EmailService {
}
}
}

View File

@ -52,5 +52,3 @@ export class SendGridEmailProvider {
}
}
}

View File

@ -20,5 +20,3 @@ export class EmailProcessor extends WorkerHost {
this.logger.debug("Processed email job");
}
}

View File

@ -28,5 +28,3 @@ export class EmailQueueService {
});
}
}

View File

@ -5,5 +5,3 @@ export const QUEUE_NAMES = {
} as const;
export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES];

View File

@ -39,5 +39,3 @@ function parseRedisConnection(redisUrl: string) {
exports: [BullModule],
})
export class QueueModule {}

View File

@ -32,5 +32,3 @@ import Redis from "ioredis";
exports: ["REDIS_CLIENT"],
})
export class RedisModule {}

View File

@ -9,5 +9,3 @@ import { FreebititModule } from "@bff/integrations/freebit/freebit.module";
exports: [WhmcsModule, SalesforceModule, FreebititModule],
})
export class IntegrationsModule {}

View File

@ -15,9 +15,7 @@ import {
CreateCaseUserData,
} from "./services/salesforce-case.service";
import { SupportCase, CreateCaseRequest } from "@customer-portal/domain";
import type {
SalesforceAccountRecord,
} from "@customer-portal/domain";
import type { SalesforceAccountRecord } from "@customer-portal/domain";
/**
* Clean Salesforce Service - Only includes actually used functionality

View File

@ -2,10 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import type {
SalesforceAccountRecord,
SalesforceQueryResult,
} from "@customer-portal/domain";
import type { SalesforceAccountRecord, SalesforceQueryResult } from "@customer-portal/domain";
export interface AccountData {
name: string;
@ -134,7 +131,7 @@ export class SalesforceAccountService {
WHERE Id = '${this.validateId(accountId)}'
`)) as SalesforceQueryResult<SalesforceAccountRecord>;
return result.totalSize > 0 ? result.records[0] ?? null : null;
return result.totalSize > 0 ? (result.records[0] ?? null) : null;
} catch (error) {
this.logger.error("Failed to get account", {
error: getErrorMessage(error),

View File

@ -1,3 +1 @@
declare module "salesforce-pubsub-api-client";

View File

@ -0,0 +1,39 @@
import { BadRequestException } from "@nestjs/common";
import { assertSalesforceId, buildInClause, sanitizeSoqlLiteral } from "../soql.util";
describe("soql.util", () => {
describe("sanitizeSoqlLiteral", () => {
it("escapes single quotes and backslashes", () => {
const raw = "O'Reilly \\"books\\"";
const result = sanitizeSoqlLiteral(raw);
expect(result).toBe("O\\'Reilly \\\\"books\\\\"");
});
it("returns original string when no escaping needed", () => {
const raw = "Sample-Value";
expect(sanitizeSoqlLiteral(raw)).toBe(raw);
});
});
describe("assertSalesforceId", () => {
it("returns the id when valid", () => {
const id = "0015g00000N1ABC";
expect(assertSalesforceId(id, "context")).toBe(id);
});
it("throws BadRequestException when id is invalid", () => {
expect(() => assertSalesforceId("invalid", "context")).toThrow(BadRequestException);
});
});
describe("buildInClause", () => {
it("builds sanitized comma separated list", () => {
const clause = buildInClause(["abc", "O'Reilly"], "field");
expect(clause).toBe("'abc', 'O\\'Reilly'");
});
it("throws when no values provided", () => {
expect(() => buildInClause([], "field")).toThrow(BadRequestException);
});
});
});

View File

@ -0,0 +1,22 @@
import { BadRequestException } from "@nestjs/common";
const SALESFORCE_ID_PATTERN = /^[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?$/;
export function sanitizeSoqlLiteral(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
export function assertSalesforceId(value: string | undefined | null, context: string): string {
if (!value || !SALESFORCE_ID_PATTERN.test(value)) {
throw new BadRequestException(`Invalid Salesforce ID for ${context}`);
}
return value;
}
export function buildInClause(values: string[], context: string): string {
if (values.length === 0) {
throw new BadRequestException(`At least one value required for ${context}`);
}
return values.map(raw => `'${sanitizeSoqlLiteral(raw)}'`).join(", ");
}

View File

@ -1,7 +1,7 @@
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Invoice, InvoiceList } from "@customer-portal/domain";
import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain";
import { WhmcsConnectionService } from "./whmcs-connection.service";
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
@ -10,6 +10,7 @@ import {
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
WhmcsInvoicesResponse,
} from "../types/whmcs-api.types";
export interface InvoiceFilters {
@ -59,65 +60,27 @@ export class WhmcsInvoiceService {
};
const response = await this.connectionService.getInvoices(params);
const transformed = this.transformInvoicesResponse(response, clientId, page, limit);
if (!response.invoices?.invoice) {
this.logger.warn(`No invoices found for client ${clientId}`);
return {
invoices: [],
pagination: {
page,
totalPages: 0,
totalItems: 0,
},
};
const parseResult = invoiceListSchema.safeParse(transformed);
if (!parseResult.success) {
this.logger.error("Failed to parse invoices response", {
error: parseResult.error.issues,
clientId,
page,
limit,
});
throw new Error("Invalid invoice response from WHMCS");
}
// Transform invoices (note: items are not included by GetInvoices API)
const invoices = response.invoices.invoice
.map(whmcsInvoice => {
try {
return this.dataTransformer.transformInvoice(whmcsInvoice);
} catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
error: getErrorMessage(error),
});
return null;
}
})
.filter((invoice): invoice is Invoice => invoice !== null);
// Build result with pagination
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
totalresults: response.totalresults,
numreturned: response.numreturned,
startnumber: response.startnumber,
actualInvoicesReturned: invoices.length,
requestParams: {
userid: clientId,
limitstart,
limitnum: limit,
orderby: "date",
order: "DESC",
},
});
const totalItems = response.totalresults || 0;
const totalPages = Math.ceil(totalItems / limit);
const result: InvoiceList = {
invoices,
pagination: {
page,
totalPages,
totalItems,
nextCursor: page < totalPages ? (page + 1).toString() : undefined,
},
};
const result = parseResult.data;
// Cache the result
await this.cacheService.setInvoicesList(userId, page, limit, status, result);
this.logger.log(`Fetched ${invoices.length} invoices for client ${clientId}, page ${page}`);
this.logger.log(
`Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}`
);
return result;
} catch (error) {
this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
@ -147,7 +110,16 @@ export class WhmcsInvoiceService {
try {
// Get detailed invoice with items
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
return detailedInvoice;
const parseResult = invoiceSchema.safeParse(detailedInvoice);
if (!parseResult.success) {
this.logger.error("Failed to parse detailed invoice", {
error: parseResult.error.issues,
invoiceId: invoice.id,
clientId,
});
throw new Error("Invalid invoice data");
}
return parseResult.data;
} catch (error) {
this.logger.warn(
`Failed to fetch details for invoice ${invoice.id}`,
@ -205,8 +177,8 @@ export class WhmcsInvoiceService {
// Transform invoice
const invoice = this.dataTransformer.transformInvoice(response);
// Validate transformation
if (!this.dataTransformer.validateInvoice(invoice)) {
const parseResult = invoiceSchema.safeParse(invoice);
if (!parseResult.success) {
throw new Error(`Invalid invoice data after transformation`);
}
@ -449,4 +421,63 @@ export class WhmcsInvoiceService {
// Default fallback
return "Unable to process payment. Please try again or contact support.";
}
private transformInvoicesResponse(
response: WhmcsInvoicesResponse,
clientId: number,
page: number,
limit: number
): InvoiceList {
if (!response.invoices?.invoice) {
this.logger.warn(`No invoices found for client ${clientId}`);
return {
invoices: [],
pagination: {
page,
totalPages: 0,
totalItems: 0,
},
} satisfies InvoiceList;
}
const invoices = response.invoices.invoice
.map(whmcsInvoice => {
try {
return this.dataTransformer.transformInvoice(whmcsInvoice);
} catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
error: getErrorMessage(error),
});
return null;
}
})
.filter((invoice): invoice is Invoice => invoice !== null);
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
totalresults: response.totalresults,
numreturned: response.numreturned,
startnumber: response.startnumber,
actualInvoicesReturned: invoices.length,
requestParams: {
userid: clientId,
limitstart: (page - 1) * limit,
limitnum: limit,
orderby: "date",
order: "DESC",
},
});
const totalItems = response.totalresults || 0;
const totalPages = Math.ceil(totalItems / limit);
return {
invoices,
pagination: {
page,
totalPages,
totalItems,
nextCursor: page < totalPages ? (page + 1).toString() : undefined,
},
} satisfies InvoiceList;
}
}

View File

@ -50,7 +50,7 @@ export class WhmcsPaymentService {
// Use consolidated array shape
const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response?.paymethods)
? (response.paymethods as WhmcsPaymentMethod[])
? response.paymethods
: [];
let methods = paymentMethodsArray
@ -69,7 +69,9 @@ export class WhmcsPaymentService {
.filter((method): method is PaymentMethod => method !== null);
// Mark the first method as default (per product decision)
methods = methods.map((m, i) => (i === 0 ? { ...m, isDefault: true } : { ...m, isDefault: false }));
methods = methods.map((m, i) =>
i === 0 ? { ...m, isDefault: true } : { ...m, isDefault: false }
);
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
if (!options?.fresh) {

View File

@ -67,12 +67,10 @@ export class WhmcsDataTransformer {
return invoice;
} catch (error) {
this.logger.error(`Failed to transform invoice ${invoiceId}`,
{
error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
}
);
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
});
throw new Error(`Failed to transform invoice: ${getErrorMessage(error)}`);
}
}
@ -132,12 +130,10 @@ export class WhmcsDataTransformer {
return subscription;
} catch (error) {
this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`,
{
error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>),
}
);
this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, {
error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>),
});
throw new Error(`Failed to transform subscription: ${getErrorMessage(error)}`);
}
}

View File

@ -32,10 +32,10 @@ for (const signal of signals) {
}
void bootstrap()
.then((startedApp) => {
.then(startedApp => {
app = startedApp;
})
.catch((error) => {
.catch(error => {
const resolvedError = error as Error;
logger.error(
`Failed to bootstrap the Nest application: ${resolvedError.message}`,

View File

@ -51,10 +51,7 @@ export class AuthController {
@ApiResponse({ status: 409, description: "Customer already has account" })
@ApiResponse({ status: 400, description: "Customer number not found" })
@ApiResponse({ status: 429, description: "Too many validation attempts" })
async validateSignup(
@Body() validateData: ValidateSignupRequestInput,
@Req() req: Request
) {
async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) {
return this.authService.validateSignup(validateData, req);
}
@ -136,13 +133,10 @@ export class AuthController {
@ApiResponse({ status: 200, description: "Token refreshed successfully" })
@ApiResponse({ status: 401, description: "Invalid refresh token" })
@ApiResponse({ status: 429, description: "Too many refresh attempts" })
async refreshToken(
@Body() body: RefreshTokenRequestInput,
@Req() req: Request
) {
async refreshToken(@Body() body: RefreshTokenRequestInput, @Req() req: Request) {
return this.authService.refreshTokens(body.refreshToken, {
deviceId: body.deviceId,
userAgent: req.headers['user-agent'],
userAgent: req.headers["user-agent"],
});
}
@ -246,7 +240,7 @@ export class AuthController {
description: "User not found or not linked to WHMCS",
})
async createSsoLink(
@Req() req: Request & { user: { id: string } },
@Req() req: Request & { user: { id: string } },
@Body() body: SsoLinkRequestInput
) {
const destination = body?.destination;

View File

@ -48,10 +48,6 @@ import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workfl
useClass: GlobalAuthGuard,
},
],
exports: [
AuthService,
TokenBlacklistService,
AuthTokenService
],
exports: [AuthService, TokenBlacklistService, AuthTokenService],
})
export class AuthModule {}

View File

@ -150,7 +150,7 @@ export class AuthService {
email: profile.email,
},
{
userAgent: request?.headers['user-agent'],
userAgent: request?.headers["user-agent"],
}
);
@ -232,7 +232,10 @@ export class AuthService {
return null;
}
} catch (error) {
this.logger.error("Password validation error", { userId: user.id, error: getErrorMessage(error) });
this.logger.error("Password validation error", {
userId: user.id,
error: getErrorMessage(error),
});
await this.auditService.logAuthEvent(
AuditAction.LOGIN_FAILED,
user.id,
@ -349,7 +352,6 @@ export class AuthService {
return sanitizeWhmcsRedirectPath(path);
}
async requestPasswordReset(email: string): Promise<void> {
await this.passwordWorkflow.requestPasswordReset(email);
}
@ -432,7 +434,10 @@ export class AuthService {
return this.passwordWorkflow.changePassword(userId, currentPassword, newPassword, request);
}
async refreshTokens(refreshToken: string, deviceInfo?: { deviceId?: string; userAgent?: string }) {
async refreshTokens(
refreshToken: string,
deviceInfo?: { deviceId?: string; userAgent?: string }
) {
return this.tokenService.refreshTokens(refreshToken, deviceInfo);
}
@ -443,5 +448,4 @@ export class AuthService {
async signupPreflight(signupData: SignupRequestInput) {
return this.signupWorkflow.signupPreflight(signupData);
}
}

View File

@ -16,7 +16,7 @@ export class AuthThrottleGuard extends ThrottlerGuard {
"unknown";
const userAgent = req.headers["user-agent"] || "unknown";
const userAgentHash = Buffer.from(userAgent).toString('base64').slice(0, 16);
const userAgentHash = Buffer.from(userAgent).toString("base64").slice(0, 16);
const resolvedIp = await Promise.resolve(ip);
return `auth_${resolvedIp}_${userAgentHash}`;

View File

@ -16,18 +16,15 @@ export class TokenBlacklistService {
async blacklistToken(token: string, _expiresIn?: number): Promise<void> {
// Validate token format first
if (!token || typeof token !== 'string' || token.split('.').length !== 3) {
if (!token || typeof token !== "string" || token.split(".").length !== 3) {
this.logger.warn("Invalid token format provided for blacklisting");
return;
}
// Use JwtService to safely decode and validate token
try {
const payload = this.jwtService.decode(token) as {
exp?: number;
sub?: string;
} | null;
const payload = this.jwtService.decode(token);
// Validate payload structure
if (!payload || !payload.sub || !payload.exp) {
this.logger.warn("Invalid JWT payload structure for blacklisting");
@ -73,5 +70,4 @@ export class TokenBlacklistService {
return false;
}
}
}

View File

@ -29,18 +29,20 @@ export class AuthTokenService {
private readonly usersService: UsersService
) {}
/**
* Generate a new token pair with refresh token rotation
*/
async generateTokenPair(user: {
id: string;
email: string;
role?: string;
}, deviceInfo?: {
deviceId?: string;
userAgent?: string;
}): Promise<AuthTokens> {
async generateTokenPair(
user: {
id: string;
email: string;
role?: string;
},
deviceInfo?: {
deviceId?: string;
userAgent?: string;
}
): Promise<AuthTokens> {
const tokenId = this.generateTokenId();
const familyId = this.generateTokenId();
@ -73,7 +75,7 @@ export class AuthTokenService {
// Store refresh token family in Redis
const refreshTokenHash = this.hashToken(refreshToken);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", { status: this.redis.status });
throw new UnauthorizedException("Session service unavailable");
@ -104,15 +106,19 @@ export class AuthTokenService {
})
);
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id
userId: user.id,
});
throw new UnauthorizedException("Unable to issue session tokens. Please try again.");
}
const accessExpiresAt = new Date(Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)).toISOString();
const refreshExpiresAt = new Date(Date.now() + this.parseExpiryToMs(this.REFRESH_TOKEN_EXPIRY)).toISOString();
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
const refreshExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.REFRESH_TOKEN_EXPIRY)
).toISOString();
this.logger.debug("Generated new token pair", { userId: user.id, tokenId, familyId });
@ -128,13 +134,16 @@ export class AuthTokenService {
/**
* Refresh access token using refresh token rotation
*/
async refreshTokens(refreshToken: string, deviceInfo?: {
deviceId?: string;
userAgent?: string;
}): Promise<AuthTokens> {
async refreshTokens(
refreshToken: string,
deviceInfo?: {
deviceId?: string;
userAgent?: string;
}
): Promise<AuthTokens> {
try {
// Verify refresh token
const payload = this.jwtService.verify(refreshToken) as RefreshTokenPayload;
const payload = this.jwtService.verify(refreshToken);
const refreshTokenHash = this.hashToken(refreshToken);
// Check if refresh token exists and is valid
@ -142,20 +151,24 @@ export class AuthTokenService {
try {
storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
} catch (error) {
this.logger.error("Redis error during token refresh", {
error: error instanceof Error ? error.message : String(error)
this.logger.error("Redis error during token refresh", {
error: error instanceof Error ? error.message : String(error),
});
throw new UnauthorizedException("Token validation temporarily unavailable");
}
if (!storedToken) {
this.logger.warn("Refresh token not found or expired", { tokenHash: refreshTokenHash.slice(0, 8) });
this.logger.warn("Refresh token not found or expired", {
tokenHash: refreshTokenHash.slice(0, 8),
});
throw new UnauthorizedException("Invalid refresh token");
}
const tokenData = JSON.parse(storedToken);
if (!tokenData.valid) {
this.logger.warn("Refresh token marked as invalid", { tokenHash: refreshTokenHash.slice(0, 8) });
this.logger.warn("Refresh token marked as invalid", {
tokenHash: refreshTokenHash.slice(0, 8),
});
// Invalidate entire token family on reuse attempt
await this.invalidateTokenFamily(tokenData.familyId);
throw new UnauthorizedException("Invalid refresh token");
@ -185,8 +198,8 @@ export class AuthTokenService {
return newTokenPair;
} catch (error) {
this.logger.error("Token refresh failed", {
error: error instanceof Error ? error.message : String(error)
this.logger.error("Token refresh failed", {
error: error instanceof Error ? error.message : String(error),
});
throw new UnauthorizedException("Invalid refresh token");
}
@ -199,17 +212,17 @@ export class AuthTokenService {
try {
const refreshTokenHash = this.hashToken(refreshToken);
const storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
if (storedToken) {
const tokenData = JSON.parse(storedToken);
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${tokenData.familyId}`);
this.logger.debug("Revoked refresh token", { tokenHash: refreshTokenHash.slice(0, 8) });
}
} catch (error) {
this.logger.error("Failed to revoke refresh token", {
error: error instanceof Error ? error.message : String(error)
this.logger.error("Failed to revoke refresh token", {
error: error instanceof Error ? error.message : String(error),
});
}
}
@ -221,7 +234,7 @@ export class AuthTokenService {
try {
const pattern = `${this.REFRESH_TOKEN_FAMILY_PREFIX}*`;
const keys = await this.redis.keys(pattern);
for (const key of keys) {
const data = await this.redis.get(key);
if (data) {
@ -232,11 +245,11 @@ export class AuthTokenService {
}
}
}
this.logger.debug("Revoked all tokens for user", { userId });
} catch (error) {
this.logger.error("Failed to revoke all user tokens", {
error: error instanceof Error ? error.message : String(error)
this.logger.error("Failed to revoke all user tokens", {
error: error instanceof Error ? error.message : String(error),
});
}
}
@ -246,18 +259,18 @@ export class AuthTokenService {
const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`);
if (familyData) {
const family = JSON.parse(familyData);
await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`);
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`);
this.logger.warn("Invalidated token family due to security concern", {
this.logger.warn("Invalidated token family due to security concern", {
familyId: familyId.slice(0, 8),
userId: family.userId
userId: family.userId,
});
}
} catch (error) {
this.logger.error("Failed to invalidate token family", {
error: error instanceof Error ? error.message : String(error)
this.logger.error("Failed to invalidate token family", {
error: error instanceof Error ? error.message : String(error),
});
}
}
@ -273,13 +286,18 @@ export class AuthTokenService {
private parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1);
const value = 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; // Default 15 minutes
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; // Default 15 minutes
}
}
@ -289,7 +307,7 @@ export class AuthTokenService {
private calculateExpiryDate(expiresIn: string | number): string {
const now = new Date();
if (typeof expiresIn === 'number') {
if (typeof expiresIn === "number") {
return new Date(now.getTime() + expiresIn * 1000).toISOString();
}
return new Date(now.getTime() + this.parseExpiryToMs(expiresIn)).toISOString();

View File

@ -1,9 +1,4 @@
import {
BadRequestException,
Inject,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { BadRequestException, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Logger } from "nestjs-pino";

View File

@ -27,10 +27,7 @@ import {
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
import type { User as PrismaUser } from "@prisma/client";
type SanitizedPrismaUser = Omit<
PrismaUser,
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
>;
type SanitizedPrismaUser = Omit<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lockedUntil">;
export interface SignupResult {
user: UserProfile;
@ -438,9 +435,8 @@ export class SignupWorkflowService {
private validateSignupData(signupData: SignupRequestInput) {
const validation = signupRequestSchema.safeParse(signupData);
if (!validation.success) {
const message = validation.error.issues
.map(issue => issue.message)
.join(". ") || "Invalid signup data";
const message =
validation.error.issues.map(issue => issue.message).join(". ") || "Invalid signup data";
throw new BadRequestException(message);
}
}

View File

@ -85,7 +85,7 @@ export class WhmcsLinkWorkflowService {
this.logger.log("Found Customer Number for WHMCS client", {
whmcsClientId: clientDetails.id,
hasCustomerNumber: !!customerNumber
hasCustomerNumber: !!customerNumber,
});
let sfAccount: { id: string } | null;
@ -97,7 +97,9 @@ export class WhmcsLinkWorkflowService {
} catch (error) {
if (error instanceof BadRequestException) throw error;
this.logger.error("Salesforce account lookup failed", { error: getErrorMessage(error) });
throw new BadRequestException("Unable to verify customer information. Please contact support.");
throw new BadRequestException(
"Unable to verify customer information. Please contact support."
);
}
const createdUser = await this.usersService.create({

View File

@ -20,7 +20,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
};
super(options);
@ -35,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}): Promise<UserProfile> {
// Validate payload structure
if (!payload.sub || !payload.email) {
throw new Error('Invalid JWT payload');
throw new Error("Invalid JWT payload");
}
// Return user info - token blacklist is checked in GlobalAuthGuard

View File

@ -2,7 +2,10 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import type { SalesforcePricebookEntryRecord } from "@customer-portal/domain";
import type {
SalesforceProduct2WithPricebookEntries,
SalesforceQueryResult,
} from "@customer-portal/domain";
@Injectable()
export class BaseCatalogService {
@ -19,49 +22,32 @@ export class BaseCatalogService {
return getSalesforceFieldMap();
}
protected async executeQuery(soql: string, context: string): Promise<Record<string, unknown>[]> {
protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>(
soql: string,
context: string
): Promise<TRecord[]> {
try {
const res = (await this.sf.query(soql)) as { records?: Array<Record<string, unknown>> };
return res.records || [];
const res = (await this.sf.query(soql)) as SalesforceQueryResult<TRecord>;
return res.records ?? [];
} catch (error) {
this.logger.error({ error, soql, context }, `Query failed: ${context}`);
return [];
}
}
protected extractPricebookEntry(
record: Record<string, unknown>
): SalesforcePricebookEntryRecord | undefined {
const nested = record["PricebookEntries"] as {
records?: Array<Record<string, unknown>>;
} | undefined;
const entry = nested?.records?.[0];
protected extractPricebookEntry(record: SalesforceProduct2WithPricebookEntries) {
const entry = record.PricebookEntries?.records?.[0];
if (!entry) {
const fields = this.getFields();
const skuField = fields.product.sku;
const sku = record[skuField];
this.logger.warn(
`No pricebook entry found for product ${String(record["Name"])} (SKU: ${String(
typeof sku === "string" || typeof sku === "number" ? sku : ""
)}). Pricebook ID: ${this.portalPriceBookId}.`
`No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.`
);
return undefined;
}
return {
Id: typeof entry["Id"] === "string" ? (entry["Id"] as string) : undefined,
Name: typeof entry["Name"] === "string" ? (entry["Name"] as string) : undefined,
UnitPrice:
typeof entry["UnitPrice"] === "number" || typeof entry["UnitPrice"] === "string"
? (entry["UnitPrice"] as number | string)
: undefined,
Pricebook2Id:
typeof entry["Pricebook2Id"] === "string" ? (entry["Pricebook2Id"] as string) : undefined,
Product2Id:
typeof entry["Product2Id"] === "string" ? (entry["Product2Id"] as string) : undefined,
IsActive:
typeof entry["IsActive"] === "boolean" ? (entry["IsActive"] as boolean) : undefined,
};
return entry;
}
protected buildProductQuery(

View File

@ -1,11 +1,20 @@
import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import type { SalesforcePricebookEntryRecord, InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem } from "@customer-portal/domain";
import type {
SalesforceProduct2WithPricebookEntries,
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { mapInternetPlan, mapInternetInstallation, mapInternetAddon } from "@bff/modules/catalog/utils/salesforce-product.mapper";
import {
mapInternetPlan,
mapInternetInstallation,
mapInternetAddon,
} from "@bff/modules/catalog/utils/salesforce-product.mapper";
interface SalesforceAccount {
Id: string;
@ -29,11 +38,14 @@ export class InternetCatalogService extends BaseCatalogService {
fields.product.internetOfferingType,
fields.product.displayOrder,
]);
const records = await this.executeQuery(soql, "Internet Plans");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Plans"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
return mapInternetPlan(record as SalesforceProduct2Record, pricebookEntry);
const entry = this.extractPricebookEntry(record);
return mapInternetPlan(record, entry);
});
}
@ -43,14 +55,17 @@ export class InternetCatalogService extends BaseCatalogService {
fields.product.billingCycle,
fields.product.displayOrder,
]);
const records = await this.executeQuery(soql, "Internet Installations");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Installations"
);
this.logger.log(`Found ${records.length} installation records`);
return records
.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
return mapInternetInstallation(record as SalesforceProduct2Record, pricebookEntry);
const entry = this.extractPricebookEntry(record);
return mapInternetInstallation(record, entry);
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
}
@ -63,14 +78,17 @@ export class InternetCatalogService extends BaseCatalogService {
fields.product.bundledAddon,
fields.product.isBundledAddon,
]);
const records = await this.executeQuery(soql, "Internet Add-ons");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Add-ons"
);
this.logger.log(`Found ${records.length} addon records`);
return records
.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
return mapInternetAddon(record as SalesforceProduct2Record, pricebookEntry);
const entry = this.extractPricebookEntry(record);
return mapInternetAddon(record, entry);
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
}

View File

@ -1,7 +1,14 @@
import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain";
import { mapSimProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper";
import type {
SalesforceProduct2WithPricebookEntries,
SimCatalogProduct,
SimActivationFeeCatalogItem,
} from "@customer-portal/domain";
import {
mapSimProduct,
mapSimActivationFee,
} from "@bff/modules/catalog/utils/salesforce-product.mapper";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino";
@ -26,17 +33,18 @@ export class SimCatalogService extends BaseCatalogService {
fields.product.simHasFamilyDiscount,
fields.product.displayOrder,
]);
const records = await this.executeQuery(soql, "SIM Plans");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"SIM Plans"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, pricebookEntry);
const entry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, entry);
return {
...product,
description: product.description ?? product.name,
monthlyPrice: product.monthlyPrice,
oneTimePrice: product.oneTimePrice,
} satisfies SimCatalogProduct;
});
}
@ -44,19 +52,14 @@ export class SimCatalogService extends BaseCatalogService {
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("SIM", "Activation", []);
const records = await this.executeQuery(soql, "SIM Activation Fees");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"SIM Activation Fees"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, pricebookEntry);
return {
...product,
description: product.description ?? product.name,
catalogMetadata: {
isDefault: true,
},
} satisfies SimActivationFeeCatalogItem;
const entry = this.extractPricebookEntry(record);
return mapSimActivationFee(record, entry);
});
}
@ -68,18 +71,19 @@ export class SimCatalogService extends BaseCatalogService {
fields.product.bundledAddon,
fields.product.isBundledAddon,
]);
const records = await this.executeQuery(soql, "SIM Add-ons");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"SIM Add-ons"
);
return records
.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, pricebookEntry);
const entry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, entry);
return {
...product,
description: product.description ?? product.name,
displayOrder:
product.displayOrder ?? Number((record as Record<string, unknown>)[fields.product.displayOrder] ?? 0),
} satisfies SimCatalogProduct;
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
@ -148,5 +152,4 @@ export class SimCatalogService extends BaseCatalogService {
]);
return { plans, activationFees, addons };
}
}

View File

@ -1,6 +1,9 @@
import { Injectable } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import type { VpnCatalogProduct } from "@customer-portal/domain";
import type {
SalesforceProduct2WithPricebookEntries,
VpnCatalogProduct,
} from "@customer-portal/domain";
import { mapVpnProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper";
@Injectable()
@ -11,15 +14,17 @@ export class VpnCatalogService extends BaseCatalogService {
fields.product.vpnRegion,
fields.product.displayOrder,
]);
const records = await this.executeQuery(soql, "VPN Plans");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"VPN Plans"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = mapVpnProduct(record, pricebookEntry);
const entry = this.extractPricebookEntry(record);
const product = mapVpnProduct(record, entry);
return {
...product,
description: product.description ?? product.name,
description: product.description || product.name,
} satisfies VpnCatalogProduct;
});
}

View File

@ -1,7 +1,7 @@
import type {
CatalogProductBase,
CatalogPricebookEntry,
SalesforceProduct2Record,
SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord,
InternetCatalogProduct,
InternetPlanCatalogItem,
@ -12,69 +12,99 @@ import type {
VpnCatalogProduct,
} from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { getMonthlyPrice, getOneTimePrice } from "@bff/modules/catalog/utils/salesforce-product.pricing";
import type { InternetPlanTemplate } from "@customer-portal/domain";
const { product: productFields } = getSalesforceFieldMap();
const fieldMap = getSalesforceFieldMap();
function normalizePricebookEntry(entry?: SalesforcePricebookEntryRecord): CatalogPricebookEntry | undefined {
export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries;
function getProductField<T = unknown>(
product: SalesforceCatalogProductRecord,
fieldKey: keyof typeof fieldMap.product
): T | undefined {
const salesforceField = fieldMap.product[fieldKey];
const value = (product as Record<string, unknown>)[salesforceField];
return value as T | undefined;
}
function getStringField(
product: SalesforceCatalogProductRecord,
fieldKey: keyof typeof fieldMap.product
): string | undefined {
const value = getProductField(product, fieldKey);
return typeof value === "string" ? value : undefined;
}
function getBooleanField(
product: SalesforceCatalogProductRecord,
fieldKey: keyof typeof fieldMap.product
): boolean | undefined {
const value = getProductField(product, fieldKey);
return typeof value === "boolean" ? value : undefined;
}
function coerceNumber(value: unknown): number | undefined {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function buildPricebookEntry(
entry?: SalesforcePricebookEntryRecord
): CatalogPricebookEntry | undefined {
if (!entry) return undefined;
return {
id: typeof entry.Id === "string" ? entry.Id : undefined,
name: typeof entry.Name === "string" ? entry.Name : undefined,
unitPrice:
typeof entry.UnitPrice === "number"
? entry.UnitPrice
: typeof entry.UnitPrice === "string"
? Number.parseFloat(entry.UnitPrice)
: undefined,
pricebook2Id: typeof entry.Pricebook2Id === "string" ? entry.Pricebook2Id : undefined,
product2Id: typeof entry.Product2Id === "string" ? entry.Product2Id : undefined,
isActive: typeof entry.IsActive === "boolean" ? entry.IsActive : undefined,
id: entry.Id,
name: entry.Name,
unitPrice: coerceNumber(entry.UnitPrice),
pricebook2Id: entry.Pricebook2Id ?? undefined,
product2Id: entry.Product2Id ?? undefined,
isActive: entry.IsActive ?? undefined,
};
}
function baseProduct(product: SalesforceProduct2Record): CatalogProductBase {
const safeRecord = product as Record<string, unknown>;
const id = typeof product.Id === "string" ? product.Id : "";
const skuField = productFields.sku;
const skuRaw = skuField ? safeRecord[skuField] : undefined;
const sku = typeof skuRaw === "string" ? skuRaw : "";
function getPricebookEntry(
product: SalesforceCatalogProductRecord
): CatalogPricebookEntry | undefined {
const nested = product.PricebookEntries?.records;
if (!Array.isArray(nested) || nested.length === 0) return undefined;
return buildPricebookEntry(nested[0]);
}
function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase {
const sku = getStringField(product, "sku") ?? "";
const base: CatalogProductBase = {
id,
id: product.Id,
sku,
name: typeof product.Name === "string" ? product.Name : sku,
name: product.Name ?? sku,
};
const description = typeof product.Description === "string" ? product.Description : undefined;
if (description) {
base.description = description;
}
const description = product.Description;
if (description) base.description = description;
const billingCycleField = productFields.billingCycle;
const billingRaw = billingCycleField ? safeRecord[billingCycleField] : undefined;
if (typeof billingRaw === "string") {
base.billingCycle = billingRaw;
}
const billingCycle = getStringField(product, "billingCycle");
if (billingCycle) base.billingCycle = billingCycle;
const displayOrderField = productFields.displayOrder;
const displayOrderRaw = displayOrderField ? safeRecord[displayOrderField] : undefined;
if (typeof displayOrderRaw === "number") {
base.displayOrder = displayOrderRaw;
}
const displayOrder = getProductField(product, "displayOrder");
if (typeof displayOrder === "number") base.displayOrder = displayOrder;
return base;
}
function extractArray(raw: unknown): string[] | undefined {
function parseFeatureList(product: SalesforceCatalogProductRecord): string[] | undefined {
const raw = getProductField(product, "featureList");
if (Array.isArray(raw)) {
return raw.filter((value): value is string => typeof value === "string");
return raw.filter((item): item is string => typeof item === "string");
}
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? extractArray(parsed) : undefined;
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: undefined;
} catch {
return undefined;
}
@ -82,165 +112,125 @@ function extractArray(raw: unknown): string[] | undefined {
return undefined;
}
export function mapInternetProduct(
product: SalesforceProduct2Record,
function derivePrices(
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetCatalogProduct {
const base = baseProduct(product);
const tierField = productFields.internetPlanTier;
const offeringTypeField = productFields.internetOfferingType;
const tier = tierField ? (product as Record<string, unknown>)[tierField] : undefined;
const offeringType = offeringTypeField
? (product as Record<string, unknown>)[offeringTypeField]
: undefined;
): Pick<CatalogProductBase, "monthlyPrice" | "oneTimePrice"> {
const billingCycle = getStringField(product, "billingCycle")?.toLowerCase();
const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined;
const rawFeatures = productFields.featureList
? (product as Record<string, unknown>)[productFields.featureList]
: undefined;
let monthlyPrice = undefined;
let oneTimePrice = undefined;
const features = extractArray(rawFeatures);
if (unitPrice !== undefined) {
if (billingCycle === "monthly") {
monthlyPrice = unitPrice;
} else if (billingCycle) {
oneTimePrice = unitPrice;
}
}
const monthlyPrice = getMonthlyPrice(product, pricebookEntry);
const oneTimePrice = getOneTimePrice(product, pricebookEntry);
if (monthlyPrice === undefined) {
const explicitMonthly = coerceNumber(getProductField(product, "monthlyPrice"));
if (explicitMonthly !== undefined) monthlyPrice = explicitMonthly;
}
return {
...base,
internetPlanTier: typeof tier === "string" ? tier : undefined,
internetOfferingType: typeof offeringType === "string" ? offeringType : undefined,
features,
monthlyPrice,
oneTimePrice,
};
}
if (oneTimePrice === undefined) {
const explicitOneTime = coerceNumber(getProductField(product, "oneTimePrice"));
if (explicitOneTime !== undefined) oneTimePrice = explicitOneTime;
}
const tierTemplates: Record<string, InternetPlanTemplate> = {
Silver: {
tierDescription: "Simple package with broadband-modem and ISP only",
description: "Simple package with broadband-modem and ISP only",
features: [
"NTT modem + ISP connection",
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
"Self-configuration of router (you provide your own)",
"Monthly: ¥6,000 | One-time: ¥22,800",
],
},
Gold: {
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
description: "Standard all-inclusive package with basic Wi-Fi",
features: [
"NTT modem + wireless router (rental)",
"ISP (IPoE) configured automatically within 24 hours",
"Basic wireless router included",
"Optional: TP-LINK RE650 range extender (¥500/month)",
"Monthly: ¥6,500 | One-time: ¥22,800",
],
},
Platinum: {
tierDescription: "Tailored set up with premier Wi-Fi management support",
description:
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
features: [
"NTT modem + Netgear INSIGHT Wi-Fi routers",
"Cloud management support for remote router management",
"Automatic updates and quicker support",
"Seamless wireless network setup",
"Monthly: ¥6,500 | One-time: ¥22,800",
"Cloud management: ¥500/month per router",
],
},
};
function getTierTemplate(tier?: string): InternetPlanTemplate {
if (!tier) return tierTemplates.Silver;
return tierTemplates[tier] ?? tierTemplates.Silver;
return { monthlyPrice, oneTimePrice };
}
export function mapInternetPlan(
product: SalesforceProduct2Record,
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetPlanCatalogItem {
const mapped = mapInternetProduct(product, pricebookEntry);
const tierData = getTierTemplate(mapped.internetPlanTier);
const base = baseProduct(product);
const prices = derivePrices(product, pricebookEntry);
const tier = getStringField(product, "internetPlanTier");
const offeringType = getStringField(product, "internetOfferingType");
const features = parseFeatureList(product);
const tierData = getTierTemplate(tier);
return {
...mapped,
description: mapped.description ?? tierData.description,
...base,
...prices,
internetPlanTier: tier,
internetOfferingType: offeringType,
features,
catalogMetadata: {
tierDescription: tierData.tierDescription,
features: tierData.features,
isRecommended: mapped.internetPlanTier === "Gold",
isRecommended: tier === "Gold",
},
description: base.description ?? tierData.description,
};
}
export function mapInternetInstallation(
product: SalesforceProduct2Record,
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetInstallationCatalogItem {
const mapped = mapInternetProduct(product, pricebookEntry);
const base = baseProduct(product);
const prices = derivePrices(product, pricebookEntry);
return {
...mapped,
...base,
...prices,
catalogMetadata: {
installationTerm: inferInstallationTypeFromSku(mapped.sku),
installationTerm: inferInstallationTypeFromSku(base.sku),
},
displayOrder: mapped.displayOrder,
};
}
export function mapInternetAddon(
product: SalesforceProduct2Record,
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetAddonCatalogItem {
const mapped = mapInternetProduct(product, pricebookEntry);
const base = baseProduct(product);
const prices = derivePrices(product, pricebookEntry);
return {
...mapped,
...base,
...prices,
catalogMetadata: {
addonCategory: inferAddonTypeFromSku(mapped.sku),
addonCategory: inferAddonTypeFromSku(base.sku),
autoAdd: false,
requiredWith: [],
},
displayOrder: mapped.displayOrder,
};
}
export function mapSimProduct(
product: SalesforceProduct2Record,
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): SimCatalogProduct {
const base = baseProduct(product);
const dataSizeField = productFields.simDataSize;
const planTypeField = productFields.simPlanType;
const familyDiscountField = productFields.simHasFamilyDiscount;
const dataSize = dataSizeField ? (product as Record<string, unknown>)[dataSizeField] : undefined;
const planType = planTypeField ? (product as Record<string, unknown>)[planTypeField] : undefined;
const familyDiscount = familyDiscountField
? (product as Record<string, unknown>)[familyDiscountField]
: undefined;
const monthlyPrice = getMonthlyPrice(product, pricebookEntry);
const oneTimePrice = getOneTimePrice(product, pricebookEntry);
const prices = derivePrices(product, pricebookEntry);
const dataSize = getStringField(product, "simDataSize");
const planType = getStringField(product, "simPlanType");
const hasFamilyDiscount = getBooleanField(product, "simHasFamilyDiscount");
return {
...base,
simDataSize: typeof dataSize === "string" ? dataSize : undefined,
simPlanType: typeof planType === "string" ? planType : undefined,
simHasFamilyDiscount: typeof familyDiscount === "boolean" ? familyDiscount : undefined,
monthlyPrice,
oneTimePrice,
...prices,
simDataSize: dataSize,
simPlanType: planType,
simHasFamilyDiscount: hasFamilyDiscount,
};
}
export function mapSimActivationFee(
product: SalesforceProduct2Record,
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): SimActivationFeeCatalogItem {
const mapped = mapSimProduct(product, pricebookEntry);
const simProduct = mapSimProduct(product, pricebookEntry);
return {
...mapped,
...simProduct,
catalogMetadata: {
isDefault: true,
},
@ -248,21 +238,16 @@ export function mapSimActivationFee(
}
export function mapVpnProduct(
product: SalesforceProduct2Record,
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord
): VpnCatalogProduct {
const base = baseProduct(product);
const regionField = productFields.vpnRegion;
const region = regionField ? (product as Record<string, unknown>)[regionField] : undefined;
const monthlyPrice = getMonthlyPrice(product, pricebookEntry);
const oneTimePrice = getOneTimePrice(product, pricebookEntry);
const prices = derivePrices(product, pricebookEntry);
const vpnRegion = getStringField(product, "vpnRegion");
return {
...base,
vpnRegion: typeof region === "string" ? region : undefined,
monthlyPrice,
oneTimePrice,
...prices,
vpnRegion,
};
}

View File

@ -1,64 +0,0 @@
import type { SalesforceProduct2Record, SalesforcePricebookEntryRecord } from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
const fields = getSalesforceFieldMap().product;
function coerceNumber(value: unknown): number | undefined {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function getUnitPrice(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): number | undefined {
const entryPrice = coerceNumber(pricebookEntry?.UnitPrice);
if (entryPrice !== undefined) return entryPrice;
const productPrice = coerceNumber((product as Record<string, unknown>)[fields.unitPrice]);
if (productPrice !== undefined) return productPrice;
const monthlyPrice = coerceNumber((product as Record<string, unknown>)[fields.monthlyPrice]);
if (monthlyPrice !== undefined) return monthlyPrice;
const oneTimePrice = coerceNumber((product as Record<string, unknown>)[fields.oneTimePrice]);
if (oneTimePrice !== undefined) return oneTimePrice;
return undefined;
}
export function getMonthlyPrice(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): number | undefined {
const unitPrice = getUnitPrice(product, pricebookEntry);
if (!unitPrice) return undefined;
const billingCycle = (product as Record<string, unknown>)[fields.billingCycle];
if (typeof billingCycle === "string" && billingCycle.toLowerCase() === "monthly") {
return unitPrice;
}
const monthlyPrice = coerceNumber((product as Record<string, unknown>)[fields.monthlyPrice]);
return monthlyPrice ?? undefined;
}
export function getOneTimePrice(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): number | undefined {
const unitPrice = getUnitPrice(product, pricebookEntry);
const billingCycle = (product as Record<string, unknown>)[fields.billingCycle];
if (typeof billingCycle === "string" && billingCycle.toLowerCase() !== "monthly") {
return unitPrice;
}
const oneTimePrice = coerceNumber((product as Record<string, unknown>)[fields.oneTimePrice]);
return oneTimePrice ?? undefined;
}

View File

@ -40,5 +40,3 @@ export class HealthController {
return { status, checks };
}
}

View File

@ -114,5 +114,3 @@ export class MappingCacheService {
return `${this.CACHE_PREFIX}:${type}:${value}`;
}
}

View File

@ -5,12 +5,8 @@ import { MappingValidatorService } from "./validation/mapping-validator.service"
import { CacheModule } from "@bff/infra/cache/cache.module";
@Module({
imports: [
CacheModule,
],
imports: [CacheModule],
providers: [MappingsService, MappingCacheService, MappingValidatorService],
exports: [MappingsService, MappingCacheService, MappingValidatorService],
})
export class MappingsModule {}

View File

@ -41,9 +41,13 @@ export class MappingsService {
const [byUser, byWhmcs, bySf] = await Promise.all([
this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }),
this.prisma.idMapping.findUnique({ where: { whmcsClientId: sanitizedRequest.whmcsClientId } }),
this.prisma.idMapping.findUnique({
where: { whmcsClientId: sanitizedRequest.whmcsClientId },
}),
sanitizedRequest.sfAccountId
? this.prisma.idMapping.findFirst({ where: { sfAccountId: sanitizedRequest.sfAccountId } })
? this.prisma.idMapping.findFirst({
where: { sfAccountId: sanitizedRequest.sfAccountId },
})
: Promise.resolve(null),
]);
@ -208,7 +212,10 @@ export class MappingsService {
}
const sanitizedUpdates = this.validator.sanitizeUpdateRequest(updates);
if (sanitizedUpdates.whmcsClientId && sanitizedUpdates.whmcsClientId !== existing.whmcsClientId) {
if (
sanitizedUpdates.whmcsClientId &&
sanitizedUpdates.whmcsClientId !== existing.whmcsClientId
) {
const conflictingMapping = await this.findByWhmcsClientId(sanitizedUpdates.whmcsClientId);
if (conflictingMapping && conflictingMapping.userId !== userId) {
throw new ConflictException(
@ -217,7 +224,10 @@ export class MappingsService {
}
}
const updated = await this.prisma.idMapping.update({ where: { userId }, data: sanitizedUpdates });
const updated = await this.prisma.idMapping.update({
where: { userId },
data: sanitizedUpdates,
});
const newMapping = this.toDomain(updated);
@ -274,7 +284,10 @@ export class MappingsService {
whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : null;
}
const dbMappings = await this.prisma.idMapping.findMany({ where: whereClause, orderBy: { createdAt: "desc" } });
const dbMappings = await this.prisma.idMapping.findMany({
where: whereClause,
orderBy: { createdAt: "desc" },
});
const mappings = dbMappings.map(mapping => this.toDomain(mapping));
this.logger.debug(`Found ${mappings.length} mappings matching filters`, filters);
return mappings;
@ -312,10 +325,15 @@ export class MappingsService {
try {
const cached = await this.cacheService.getByUserId(userId);
if (cached) return true;
const mapping = await this.prisma.idMapping.findUnique({ where: { userId }, select: { userId: true } });
const mapping = await this.prisma.idMapping.findUnique({
where: { userId },
select: { userId: true },
});
return mapping !== null;
} catch (error) {
this.logger.error(`Failed to check mapping for user ${userId}`, { error: getErrorMessage(error) });
this.logger.error(`Failed to check mapping for user ${userId}`, {
error: getErrorMessage(error),
});
return false;
}
}

View File

@ -5,11 +5,7 @@ import type {
UpdateMappingRequest,
} from "../validation/mapping-validator.service";
export type {
UserIdMapping,
CreateMappingRequest,
UpdateMappingRequest,
};
export type { UserIdMapping, CreateMappingRequest, UpdateMappingRequest };
export interface MappingSearchFilters {
userId?: string;
@ -59,5 +55,3 @@ export interface CachedMapping {
cachedAt: Date;
ttl: number;
}

View File

@ -6,12 +6,12 @@ import { z } from "zod";
const createMappingRequestSchema = z.object({
userId: z.string().uuid(),
whmcsClientId: z.number().int().positive(),
sfAccountId: z.string().optional()
sfAccountId: z.string().optional(),
});
const updateMappingRequestSchema = z.object({
whmcsClientId: z.number().int().positive().optional(),
sfAccountId: z.string().optional()
sfAccountId: z.string().optional(),
});
const userIdMappingSchema = z.object({
@ -20,7 +20,7 @@ const userIdMappingSchema = z.object({
whmcsClientId: z.number().int().positive(),
sfAccountId: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
});
export type CreateMappingRequest = z.infer<typeof createMappingRequestSchema>;
@ -40,7 +40,7 @@ export class MappingValidatorService {
validateCreateRequest(request: CreateMappingRequest): MappingValidationResult {
const validationResult = createMappingRequestSchema.safeParse(request);
if (validationResult.success) {
const warnings: string[] = [];
if (!request.sfAccountId) {
@ -51,7 +51,7 @@ export class MappingValidatorService {
const errors = validationResult.error.issues.map(issue => issue.message);
this.logger.warn({ request, errors }, "Create mapping request validation failed");
return { isValid: false, errors, warnings: [] };
}
@ -62,26 +62,26 @@ export class MappingValidatorService {
return {
isValid: false,
errors: ["User ID must be a valid UUID"],
warnings: []
warnings: [],
};
}
// Then validate the update request
const validationResult = updateMappingRequestSchema.safeParse(request);
if (validationResult.success) {
return { isValid: true, errors: [], warnings: [] };
}
const errors = validationResult.error.issues.map(issue => issue.message);
this.logger.warn({ userId, request, errors }, "Update mapping request validation failed");
return { isValid: false, errors, warnings: [] };
}
validateExistingMapping(mapping: UserIdMapping): MappingValidationResult {
const validationResult = userIdMappingSchema.safeParse(mapping);
if (validationResult.success) {
const warnings: string[] = [];
if (!mapping.sfAccountId) {
@ -92,18 +92,23 @@ export class MappingValidatorService {
const errors = validationResult.error.issues.map(issue => issue.message);
this.logger.warn({ mapping, errors }, "Existing mapping validation failed");
return { isValid: false, errors, warnings: [] };
}
validateBulkMappings(mappings: CreateMappingRequest[]): Array<{ index: number; validation: MappingValidationResult }> {
return mappings.map((mapping, index) => ({
index,
validation: this.validateCreateRequest(mapping)
validateBulkMappings(
mappings: CreateMappingRequest[]
): Array<{ index: number; validation: MappingValidationResult }> {
return mappings.map((mapping, index) => ({
index,
validation: this.validateCreateRequest(mapping),
}));
}
validateNoConflicts(request: CreateMappingRequest, existingMappings: UserIdMapping[]): MappingValidationResult {
validateNoConflicts(
request: CreateMappingRequest,
existingMappings: UserIdMapping[]
): MappingValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
@ -121,13 +126,17 @@ export class MappingValidatorService {
const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId);
if (duplicateWhmcs) {
errors.push(`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`);
errors.push(
`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`
);
}
if (request.sfAccountId) {
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
if (duplicateSf) {
warnings.push(`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`);
warnings.push(
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
);
}
}
@ -149,9 +158,13 @@ export class MappingValidatorService {
return formatValidation;
}
warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user");
warnings.push(
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"
);
if (mapping.sfAccountId) {
warnings.push("This mapping includes Salesforce integration - deletion will affect case management");
warnings.push(
"This mapping includes Salesforce integration - deletion will affect case management"
);
}
return { isValid: true, errors, warnings };
@ -179,18 +192,18 @@ export class MappingValidatorService {
sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
const sanitized: any = {};
if (request.whmcsClientId !== undefined) {
sanitized.whmcsClientId = request.whmcsClientId;
}
if (request.sfAccountId !== undefined) {
sanitized.sfAccountId = request.sfAccountId?.trim() || undefined;
}
// Use Zod parsing to validate the sanitized data
const validationResult = updateMappingRequestSchema.safeParse(sanitized);
if (validationResult.success) {
return validationResult.data;
}
@ -198,4 +211,4 @@ export class MappingValidatorService {
// Fallback to sanitized data if validation fails
return sanitized;
}
}
}

View File

@ -4,10 +4,7 @@ import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@ne
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "@bff/core/validation";
import {
createOrderRequestSchema,
type CreateOrderRequest
} from "@customer-portal/domain";
import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain";
@ApiTags("orders")
@Controller("orders")

View File

@ -8,6 +8,7 @@ import { UsersModule } from "@bff/modules/users/users.module";
import { OrderValidator } from "./services/order-validator.service";
import { OrderBuilder } from "./services/order-builder.service";
import { OrderItemBuilder } from "./services/order-item-builder.service";
import { OrderPricebookService } from "./services/order-pricebook.service";
import { OrderOrchestrator } from "./services/order-orchestrator.service";
// Clean modular fulfillment services
@ -27,6 +28,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
OrderValidator,
OrderBuilder,
OrderItemBuilder,
OrderPricebookService,
OrderOrchestrator,
// Order fulfillment services (modular)

View File

@ -4,9 +4,26 @@ import type { OrderBusinessValidation, UserMapping } from "@customer-portal/doma
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { UsersService } from "@bff/modules/users/users.service";
/**
* Handles building order header data from selections
*/
const fieldMap = getSalesforceFieldMap();
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
if (typeof value === "string" && value.trim().length > 0) {
target[key] = value;
}
}
function orderField(key: keyof typeof fieldMap.order): string {
return fieldMap.order[key];
}
function mnpField(key: keyof typeof fieldMap.order.mnp): string {
return fieldMap.order.mnp[key];
}
function billingField(key: keyof typeof fieldMap.order.billing): string {
return fieldMap.order.billing[key];
}
@Injectable()
export class OrderBuilder {
constructor(
@ -14,31 +31,25 @@ export class OrderBuilder {
private readonly usersService: UsersService
) {}
/**
* Build order fields for Salesforce Order creation
*/
async buildOrderFields(
body: OrderBusinessValidation,
userMapping: UserMapping,
pricebookId: string,
userId: string
): Promise<Record<string, unknown>> {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const fields = getSalesforceFieldMap();
const today = new Date().toISOString().slice(0, 10);
const orderFields: Record<string, unknown> = {
AccountId: userMapping.sfAccountId,
EffectiveDate: today,
Status: "Pending Review",
Pricebook2Id: pricebookId,
[fields.order.orderType]: body.orderType,
[orderField("orderType")]: body.orderType,
...(body.opportunityId ? { OpportunityId: body.opportunityId } : {}),
};
// Add activation fields
this.addActivationFields(orderFields, body);
// Add service-specific fields (only user configuration choices)
switch (body.orderType) {
case "Internet":
this.addInternetFields(orderFields, body);
@ -51,139 +62,94 @@ export class OrderBuilder {
break;
}
// Add address snapshot (single address for both billing and shipping)
await this.addAddressSnapshot(orderFields, userId, body);
return orderFields;
}
private addActivationFields(orderFields: Record<string, unknown>, body: OrderBusinessValidation): void {
const fields = getSalesforceFieldMap();
private addActivationFields(
orderFields: Record<string, unknown>,
body: OrderBusinessValidation
): void {
const config = body.configurations || {};
if (config.activationType) {
orderFields[fields.order.activationType] = config.activationType;
}
if (config.scheduledAt) {
orderFields[fields.order.activationScheduledAt] = config.scheduledAt;
}
orderFields[fields.order.activationStatus] = "Not Started";
assignIfString(orderFields, orderField("activationType"), config.activationType);
assignIfString(orderFields, orderField("activationScheduledAt"), config.scheduledAt);
orderFields[orderField("activationStatus")] = "Not Started";
}
private addInternetFields(orderFields: Record<string, unknown>, body: OrderBusinessValidation): void {
const fields = getSalesforceFieldMap();
private addInternetFields(
orderFields: Record<string, unknown>,
body: OrderBusinessValidation
): void {
const config = body.configurations || {};
// Only store user configuration choices that cannot be derived from OrderItems
if (config.accessMode) {
orderFields[fields.order.accessMode] = config.accessMode;
}
// Note: Removed fields that can be derived from OrderItems:
// - internetPlanTier: derive from service product metadata
// - installationType: derive from install product name
// - weekendInstall: derive from SKU analysis
// - hikariDenwa: derive from SKU analysis
assignIfString(orderFields, orderField("accessMode"), config.accessMode);
}
private addSimFields(orderFields: Record<string, unknown>, body: OrderBusinessValidation): void {
const fields = getSalesforceFieldMap();
const config = body.configurations || {};
assignIfString(orderFields, orderField("simType"), config.simType);
assignIfString(orderFields, orderField("eid"), config.eid);
// Only store user configuration choices that cannot be derived from OrderItems
if (config.simType) {
orderFields[fields.order.simType] = config.simType;
}
if (config.eid) {
orderFields[fields.order.eid] = config.eid;
}
// Note: Removed fields that can be derived from OrderItems:
// - simVoiceMail: derive from SKU analysis
// - simCallWaiting: derive from SKU analysis
// MNP fields
if (config.isMnp === "true") {
orderFields[fields.order.mnp.application] = true;
if (config.mnpNumber) {
orderFields[fields.order.mnp.reservationNumber] = config.mnpNumber;
}
if (config.mnpExpiry) {
orderFields[fields.order.mnp.expiryDate] = config.mnpExpiry;
}
if (config.mnpPhone) {
orderFields[fields.order.mnp.phoneNumber] = config.mnpPhone;
}
if (config.mvnoAccountNumber) {
orderFields[fields.order.mnp.mvnoAccountNumber] = config.mvnoAccountNumber;
}
if (config.portingLastName) {
orderFields[fields.order.mnp.portingLastName] = config.portingLastName;
}
if (config.portingFirstName) {
orderFields[fields.order.mnp.portingFirstName] = config.portingFirstName;
}
if (config.portingLastNameKatakana) {
orderFields[fields.order.mnp.portingLastNameKatakana] = config.portingLastNameKatakana;
}
if (config.portingFirstNameKatakana) {
orderFields[fields.order.mnp.portingFirstNameKatakana] = config.portingFirstNameKatakana;
}
if (config.portingGender) {
orderFields[fields.order.mnp.portingGender] = config.portingGender;
}
if (config.portingDateOfBirth) {
orderFields[fields.order.mnp.portingDateOfBirth] = config.portingDateOfBirth;
}
orderFields[mnpField("application")] = true;
assignIfString(orderFields, mnpField("reservationNumber"), config.mnpNumber);
assignIfString(orderFields, mnpField("expiryDate"), config.mnpExpiry);
assignIfString(orderFields, mnpField("phoneNumber"), config.mnpPhone);
assignIfString(orderFields, mnpField("mvnoAccountNumber"), config.mvnoAccountNumber);
assignIfString(orderFields, mnpField("portingLastName"), config.portingLastName);
assignIfString(orderFields, mnpField("portingFirstName"), config.portingFirstName);
assignIfString(
orderFields,
mnpField("portingLastNameKatakana"),
config.portingLastNameKatakana
);
assignIfString(
orderFields,
mnpField("portingFirstNameKatakana"),
config.portingFirstNameKatakana
);
assignIfString(orderFields, mnpField("portingGender"), config.portingGender);
assignIfString(orderFields, mnpField("portingDateOfBirth"), config.portingDateOfBirth);
}
}
private addVpnFields(_orderFields: Record<string, unknown>, _body: OrderBusinessValidation): void {
// Note: Removed vpnRegion field - can be derived from service product metadata in OrderItems
// VPN orders only need user configuration choices (none currently defined)
private addVpnFields(
_orderFields: Record<string, unknown>,
_body: OrderBusinessValidation
): void {
// No additional fields for VPN orders at this time.
}
/**
* Add address snapshot to order
* Always captures current address in billing fields and flags if changed
*/
private async addAddressSnapshot(
orderFields: Record<string, unknown>,
userId: string,
body: OrderBusinessValidation
): Promise<void> {
try {
const fields = getSalesforceFieldMap();
const address = await this.usersService.getAddress(userId);
// Check if address was provided/updated in the order request
const orderAddress = (body.configurations as Record<string, unknown>)?.address as
| Record<string, unknown>
| undefined;
const addressChanged = !!orderAddress;
// Use order address if provided, otherwise use current WHMCS address
const addressToUse = orderAddress || address;
// Always populate billing address fields (even if empty)
// Combine street and streetLine2 for Salesforce BillToStreet field
const street = typeof addressToUse?.street === "string" ? addressToUse.street : "";
const streetLine2 =
typeof addressToUse?.streetLine2 === "string" ? addressToUse.streetLine2 : "";
const fullStreet = [street, streetLine2].filter(Boolean).join(", ");
orderFields[fields.order.billing.street] = fullStreet || "";
orderFields[fields.order.billing.city] =
orderFields[billingField("street")] = fullStreet;
orderFields[billingField("city")] =
typeof addressToUse?.city === "string" ? addressToUse.city : "";
orderFields[fields.order.billing.state] =
orderFields[billingField("state")] =
typeof addressToUse?.state === "string" ? addressToUse.state : "";
orderFields[fields.order.billing.postalCode] =
orderFields[billingField("postalCode")] =
typeof addressToUse?.postalCode === "string" ? addressToUse.postalCode : "";
orderFields[fields.order.billing.country] =
orderFields[billingField("country")] =
typeof addressToUse?.country === "string" ? addressToUse.country : "";
// Set Address_Changed flag if customer updated address during checkout
orderFields[fields.order.addressChanged] = addressChanged;
orderFields[orderField("addressChanged")] = addressChanged;
if (addressChanged) {
this.logger.log({ userId }, "Customer updated address during checkout");
@ -198,8 +164,13 @@ export class OrderBuilder {
"Address snapshot added to order"
);
} catch (error) {
this.logger.error({ userId, error }, "Failed to add address snapshot to order");
// Don't fail the order creation, but log the issue
this.logger.warn(
{
userId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to add address snapshot"
);
}
}
}

View File

@ -1,7 +1,10 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { WhmcsOrderService, WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service";
import {
WhmcsOrderService,
WhmcsOrderResult,
} from "@bff/integrations/whmcs/services/whmcs-order.service";
import { OrderOrchestrator } from "./order-orchestrator.service";
import {
OrderFulfillmentValidator,
@ -12,6 +15,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"
import { SimFulfillmentService } from "./sim-fulfillment.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import type { OrderDetailsResponse } from "@customer-portal/domain";
export interface OrderFulfillmentStep {
step: string;
@ -21,13 +25,11 @@ export interface OrderFulfillmentStep {
error?: string;
}
import { OrderDetailsDto } from "../types/order-details.dto";
export interface OrderFulfillmentContext {
sfOrderId: string;
idempotencyKey: string;
validation: OrderFulfillmentValidationResult;
orderDetails?: OrderDetailsDto; // Transformed order with items
orderDetails?: OrderDetailsResponse;
mappingResult?: OrderItemMappingResult;
whmcsResult?: WhmcsOrderResult;
steps: OrderFulfillmentStep[];

View File

@ -6,12 +6,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SalesforceOrderRecord } from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import {
userMappingValidationSchema,
paymentMethodValidationSchema,
type UserMappingValidation,
type PaymentMethodValidation
} from "@customer-portal/domain";
const fieldMap = getSalesforceFieldMap();
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrderRecord;
@ -51,8 +47,7 @@ export class OrderFulfillmentValidator {
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
// 2. Check if already provisioned (idempotency)
const fields = getSalesforceFieldMap();
const rawWhmcs = (sfOrder as unknown as Record<string, unknown>)[fields.order.whmcsOrderId];
const rawWhmcs = (sfOrder as Record<string, unknown>)[fieldMap.order.whmcsOrderId];
const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined;
if (existingWhmcsOrderId) {
this.logger.log("Order already provisioned", {
@ -69,8 +64,8 @@ export class OrderFulfillmentValidator {
}
// 3. Get WHMCS client mapping
const accountId = (sfOrder as unknown as { AccountId?: unknown })?.AccountId;
if (typeof accountId !== "string" || !accountId) {
const accountId = sfOrder.AccountId;
if (typeof accountId !== "string" || accountId.length === 0) {
throw new BadRequestException("Salesforce order is missing AccountId");
}
const mapping = await this.mappingsService.findBySfAccountId(accountId);
@ -113,40 +108,18 @@ export class OrderFulfillmentValidator {
throw new BadRequestException(`Salesforce order ${sfOrderId} not found`);
}
// Cast to SalesforceOrder for type safety
const salesforceOrder = order as unknown as SalesforceOrder;
// Validate order is in a state that can be provisioned
if (salesforceOrder.Status === "Cancelled") {
if (order.Status === "Cancelled") {
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
}
const fields = getSalesforceFieldMap();
this.logger.log("Salesforce order validated", {
sfOrderId,
status: salesforceOrder.Status,
activationStatus: ((): string | undefined => {
const v = (salesforceOrder as unknown as Record<string, unknown>)[
fields.order.activationStatus
];
return typeof v === "string" ? v : undefined;
})(),
accountId: (salesforceOrder as unknown as { AccountId?: unknown })?.AccountId,
status: order.Status,
activationStatus: pickOrderString(order, "activationStatus"),
accountId: order.AccountId,
});
return salesforceOrder;
}
/**
* Get WHMCS client ID from Salesforce account ID using mappings
*/
private async getWhmcsClientId(sfAccountId: string): Promise<number> {
// Deprecated: retained for compatibility if referenced elsewhere
const mapping = await this.mappingsService.findBySfAccountId(sfAccountId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`);
}
return mapping.whmcsClientId;
return order;
}
/**
@ -181,3 +154,11 @@ export class OrderFulfillmentValidator {
}
}
}
function pickOrderString(
order: SalesforceOrderRecord,
key: keyof typeof fieldMap.order
): string | undefined {
const raw = (order as Record<string, unknown>)[fieldMap.order[key]];
return typeof raw === "string" ? raw : undefined;
}

View File

@ -1,12 +1,7 @@
import { Injectable, BadRequestException, NotFoundException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import type {
SalesforcePricebookEntryRecord,
SalesforceProduct2Record,
SalesforceQueryResult,
} from "@customer-portal/domain";
import { OrderPricebookService } from "./order-pricebook.service";
/**
* Handles building order items from SKU data
@ -15,7 +10,8 @@ import type {
export class OrderItemBuilder {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly sf: SalesforceConnection
private readonly sf: SalesforceConnection,
private readonly pricebookService: OrderPricebookService
) {}
/**
@ -32,24 +28,33 @@ export class OrderItemBuilder {
this.logger.log({ orderId, skus }, "Creating OrderItems from SKU array");
const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, skus);
// Create OrderItems for each SKU
for (const sku of skus) {
const meta = await this.getProductMetaBySku(pricebookId, sku);
const normalizedSkuValue = sku?.trim();
if (!normalizedSkuValue) {
this.logger.error({ orderId }, "Encountered empty SKU while creating order items");
throw new BadRequestException("Product SKU is required");
}
if (!meta?.pbeId) {
const normalizedSku = normalizedSkuValue.toUpperCase();
const meta = metaMap.get(normalizedSku);
if (!meta?.pricebookEntryId) {
this.logger.error({ sku }, "PricebookEntry not found for SKU");
throw new NotFoundException(`Product not found: ${sku}`);
}
if (!meta.unitPrice) {
this.logger.error({ sku, pbeId: meta.pbeId }, "PricebookEntry missing UnitPrice");
throw new Error(`PricebookEntry for SKU ${sku} has no UnitPrice set`);
this.logger.error({ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, "PricebookEntry missing UnitPrice");
throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`);
}
this.logger.log(
{
sku,
pbeId: meta.pbeId,
sku: normalizedSkuValue,
pbeId: meta.pricebookEntryId,
unitPrice: meta.unitPrice,
},
"Creating OrderItem"
@ -59,127 +64,19 @@ export class OrderItemBuilder {
// Salesforce requires explicit UnitPrice even with PricebookEntryId
await this.sf.sobject("OrderItem").create({
OrderId: orderId,
PricebookEntryId: meta.pbeId,
PricebookEntryId: meta.pricebookEntryId,
Quantity: 1,
UnitPrice: meta.unitPrice,
});
this.logger.log({ orderId, sku }, "OrderItem created successfully");
this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully");
} catch (error) {
this.logger.error({ error, orderId, sku }, "Failed to create OrderItem");
this.logger.error({ error, orderId, sku: normalizedSkuValue }, "Failed to create OrderItem");
throw error;
}
}
}
/**
* Find Portal pricebook ID
*/
async findPortalPricebookId(): Promise<string> {
const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal";
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`;
try {
const result = (await this.sf.query(soql)) as SalesforceQueryResult<{ Id?: string }>;
if (result.records?.length) {
return result.records[0]?.Id ?? "";
}
const std = (await this.sf.query(
"SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1"
)) as SalesforceQueryResult<{ Id?: string }>;
return std.records?.[0]?.Id ?? "";
} catch (error) {
this.logger.error({ error }, "Failed to find pricebook");
throw new NotFoundException("Portal pricebook not found or inactive");
}
}
/**
* Get product metadata by SKU
*/
async getProductMetaBySku(
pricebookId: string,
sku: string
): Promise<{
pbeId: string;
product2Id: string;
unitPrice?: number;
itemClass?: string;
internetOfferingType?: string;
internetPlanTier?: string;
vpnRegion?: string;
} | null> {
if (!sku) return null;
const fields = getSalesforceFieldMap();
const safeSku = sku.replace(/'/g, "\\'");
const soql =
`SELECT Id, Product2Id, UnitPrice, ` +
`Product2.${fields.product.itemClass}, ` +
`Product2.${fields.product.internetOfferingType}, ` +
`Product2.${fields.product.internetPlanTier}, ` +
`Product2.${fields.product.vpnRegion} ` +
`FROM PricebookEntry ` +
`WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${fields.product.sku}='${safeSku}' ` +
`LIMIT 1`;
try {
this.logger.debug({ sku, pricebookId }, "Querying PricebookEntry for SKU");
const res = (await this.sf.query(soql)) as SalesforceQueryResult<
SalesforcePricebookEntryRecord & {
Product2?: SalesforceProduct2Record | null;
}
>;
this.logger.debug(
{
sku,
found: !!res.records?.length,
hasPrice: res.records?.[0]?.UnitPrice != null,
},
"PricebookEntry query result"
);
const rec = res.records?.[0];
if (!rec?.Id) return null;
const product2 = rec.Product2 ?? null;
return {
pbeId: rec.Id,
product2Id: rec.Product2Id ?? "",
unitPrice: typeof rec.UnitPrice === "number" ? rec.UnitPrice : undefined,
itemClass: (() => {
const value = product2 ? (product2 as Record<string, unknown>)[fields.product.itemClass] : undefined;
return typeof value === "string" ? value : undefined;
})(),
internetOfferingType: (() => {
const value = product2
? (product2 as Record<string, unknown>)[fields.product.internetOfferingType]
: undefined;
return typeof value === "string" ? value : undefined;
})(),
internetPlanTier: (() => {
const value = product2
? (product2 as Record<string, unknown>)[fields.product.internetPlanTier]
: undefined;
return typeof value === "string" ? value : undefined;
})(),
vpnRegion: (() => {
const value = product2 ? (product2 as Record<string, unknown>)[fields.product.vpnRegion] : undefined;
return typeof value === "string" ? value : undefined;
})(),
};
} catch (error) {
this.logger.error({ error, sku }, "Failed to get product metadata");
return null;
}
}
/**
* Get service product metadata from SKU array for order header defaults
*/
@ -196,11 +93,29 @@ export class OrderItemBuilder {
// Find installation SKU
const installSku = skus.find(sku => sku.toUpperCase().includes("INSTALL"));
const [serviceProduct, installProduct] = await Promise.all([
serviceSku ? this.getProductMetaBySku(pricebookId, serviceSku) : null,
installSku ? this.getProductMetaBySku(pricebookId, installSku) : null,
]);
const targets = [serviceSku, installSku].filter(
(sku): sku is string => typeof sku === "string" && sku.length > 0
);
const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, targets);
return { serviceProduct, installProduct };
const mapMeta = (sku?: string | null) => {
if (!sku) return null;
const entry = metaMap.get(sku.trim().toUpperCase());
if (!entry) return null;
return {
pbeId: entry.pricebookEntryId,
product2Id: entry.product2Id ?? "",
unitPrice: entry.unitPrice,
itemClass: entry.itemClass,
internetOfferingType: entry.internetOfferingType,
internetPlanTier: entry.internetPlanTier,
vpnRegion: entry.vpnRegion,
};
};
return {
serviceProduct: mapMeta(serviceSku),
installProduct: mapMeta(installSku),
};
}
}

View File

@ -4,24 +4,98 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale
import { OrderValidator } from "./order-validator.service";
import { OrderBuilder } from "./order-builder.service";
import { OrderItemBuilder } from "./order-item-builder.service";
import type {
SalesforceOrderRecord,
SalesforceOrderItemRecord,
SalesforceProduct2Record,
SalesforceQueryResult,
} from "@customer-portal/domain";
import {
orderDetailsSchema,
orderSummarySchema,
type OrderDetailsResponse,
type OrderSummaryResponse,
} from "@customer-portal/domain/validation/api/responses";
type OrderItemSummary,
type SalesforceOrderRecord,
type SalesforceOrderItemRecord,
type SalesforceQueryResult,
} from "@customer-portal/domain";
import {
getSalesforceFieldMap,
getOrderQueryFields,
getOrderItemProduct2Select,
} from "@bff/core/config/field-map";
import type { SalesforceFieldMap } from "@bff/core/config/field-map";
import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/utils/soql.util";
const fieldMap = getSalesforceFieldMap();
function getOrderStringField(
order: SalesforceOrderRecord,
key: keyof typeof fieldMap.order
): string | undefined {
const raw = (order as Record<string, unknown>)[fieldMap.order[key]];
return typeof raw === "string" ? raw : undefined;
}
function pickProductString(
product: Record<string, unknown> | undefined,
key: keyof typeof fieldMap.product
): string | undefined {
if (!product) return undefined;
const raw = product[fieldMap.product[key]];
return typeof raw === "string" ? raw : undefined;
}
function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails {
const product = record.PricebookEntry?.Product2 as Record<string, unknown> | undefined;
return {
id: record.Id ?? "",
orderId: record.OrderId ?? "",
quantity: record.Quantity ?? 0,
unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined,
totalPrice: typeof record.TotalPrice === "number" ? record.TotalPrice : undefined,
billingCycle: typeof record.Billing_Cycle__c === "string" ? record.Billing_Cycle__c : undefined,
product: {
id: product?.Id,
name: product?.Name,
sku: pickProductString(product, "sku"),
itemClass: pickProductString(product, "itemClass"),
whmcsProductId: pickProductString(product, "whmcsProductId"),
internetOfferingType: pickProductString(product, "internetOfferingType"),
internetPlanTier: pickProductString(product, "internetPlanTier"),
vpnRegion: pickProductString(product, "vpnRegion"),
},
};
}
function toOrderItemSummary(details: ParsedOrderItemDetails): OrderItemSummary {
return {
orderId: details.orderId,
product: {
name: details.product.name,
sku: details.product.sku,
itemClass: details.product.itemClass,
},
quantity: details.quantity,
unitPrice: details.unitPrice,
totalPrice: details.totalPrice,
billingCycle: details.billingCycle,
};
}
interface ParsedOrderItemDetails {
id: string;
orderId: string;
quantity: number;
unitPrice?: number;
totalPrice?: number;
billingCycle?: string;
product: {
id?: string;
name?: string;
sku?: string;
itemClass?: string;
whmcsProductId?: string;
internetOfferingType?: string;
internetPlanTier?: string;
vpnRegion?: string;
};
}
/**
* Main orchestrator for order operations
@ -57,12 +131,11 @@ export class OrderOrchestrator {
);
// 2) Build order fields (includes address snapshot)
const orderFieldsInput = { ...validatedBody, userId };
const orderFields = await this.orderBuilder.buildOrderFields(
orderFieldsInput,
validatedBody,
userMapping,
pricebookId,
userId
validatedBody.userId
);
this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order");
@ -109,14 +182,14 @@ export class OrderOrchestrator {
* Get order by ID with order items
*/
async getOrder(orderId: string): Promise<OrderDetailsResponse | null> {
this.logger.log({ orderId }, "Fetching order details with items");
const safeOrderId = assertSalesforceId(orderId, "orderId");
this.logger.log({ orderId: safeOrderId }, "Fetching order details with items");
const fields = getSalesforceFieldMap();
const orderSoql = `
SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount,
Account.Name, CreatedDate, LastModifiedDate
FROM Order
WHERE Id = '${orderId}'
WHERE Id = '${safeOrderId}'
LIMIT 1
`;
@ -125,16 +198,14 @@ export class OrderOrchestrator {
PricebookEntry.Id,
${getOrderItemProduct2Select()}
FROM OrderItem
WHERE OrderId = '${orderId}'
WHERE OrderId = '${safeOrderId}'
ORDER BY CreatedDate ASC
`;
try {
const [orderResult, itemsResult] = await Promise.all([
this.sf.query(orderSoql) as Promise<SalesforceQueryResult<SalesforceOrderRecord>>,
this.sf.query(orderItemsSoql) as Promise<
SalesforceQueryResult<SalesforceOrderItemRecord>
>,
this.sf.query(orderItemsSoql) as Promise<SalesforceQueryResult<SalesforceOrderItemRecord>>,
]);
const order = orderResult.records?.[0];
@ -144,35 +215,46 @@ export class OrderOrchestrator {
return null;
}
const orderItems = (itemsResult.records ?? []).map(item =>
mapOrderItemForDetails(item, fields)
);
const orderItems = (itemsResult.records ?? []).map(mapOrderItemRecord);
this.logger.log(
{ orderId, itemCount: orderItems.length },
{ orderId: safeOrderId, itemCount: orderItems.length },
"Order details retrieved with items"
);
// Transform raw Salesforce data to domain types
const domainOrder = order;
const domainOrderItems = orderItems.map(item => mapOrderItemForSummary(item, fields));
return orderDetailsSchema.parse({
id: domainOrder.Id,
orderNumber: domainOrder.OrderNumber,
status: domainOrder.Status,
accountId: domainOrder.AccountId,
orderType: (domainOrder as any).OrderType || (domainOrder as any).Type,
effectiveDate: domainOrder.EffectiveDate,
totalAmount: domainOrder.TotalAmount,
id: order.Id,
orderNumber: order.OrderNumber,
status: order.Status,
accountId: order.AccountId,
orderType: getOrderStringField(order, "orderType") ?? order.Type,
effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount ?? 0,
accountName: order.Account?.Name,
createdDate: domainOrder.CreatedDate,
lastModifiedDate: domainOrder.LastModifiedDate,
activationType: (order as any)[fields.order.activationType],
activationStatus: (order as any)[fields.order.activationStatus],
scheduledAt: (order as any)[fields.order.activationScheduledAt],
whmcsOrderId: (order as any)[fields.order.whmcsOrderId],
items: domainOrderItems,
createdDate: order.CreatedDate,
lastModifiedDate: order.LastModifiedDate,
activationType: getOrderStringField(order, "activationType"),
activationStatus: getOrderStringField(order, "activationStatus"),
scheduledAt: getOrderStringField(order, "activationScheduledAt"),
whmcsOrderId: getOrderStringField(order, "whmcsOrderId"),
items: orderItems.map(detail => ({
id: detail.id,
orderId: detail.orderId,
quantity: detail.quantity,
unitPrice: detail.unitPrice,
totalPrice: detail.totalPrice,
billingCycle: detail.billingCycle,
product: {
id: detail.product.id,
name: detail.product.name,
sku: detail.product.sku,
itemClass: detail.product.itemClass,
whmcsProductId: detail.product.whmcsProductId,
internetOfferingType: detail.product.internetOfferingType,
internetPlanTier: detail.product.internetPlanTier,
vpnRegion: detail.product.vpnRegion,
},
})),
});
} catch (error) {
this.logger.error({ error, orderId }, "Failed to fetch order with items");
@ -188,20 +270,26 @@ export class OrderOrchestrator {
// Get user mapping
const userMapping = await this.orderValidator.validateUserMapping(userId);
const sfAccountId = userMapping.sfAccountId
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId")
: undefined;
if (!sfAccountId) {
this.logger.warn({ userId }, "User mapping missing Salesforce account ID");
return [];
}
const fields = getSalesforceFieldMap();
const ordersSoql = `
SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate
FROM Order
WHERE AccountId = '${userMapping.sfAccountId}'
WHERE AccountId = '${sfAccountId}'
ORDER BY CreatedDate DESC
LIMIT 50
`;
try {
const ordersResult = (await this.sf.query(
ordersSoql
)) as SalesforceQueryResult<SalesforceOrderRecord>;
const ordersResult = (await this.sf.query(
ordersSoql
)) as SalesforceQueryResult<SalesforceOrderRecord>;
const orders = ordersResult.records || [];
if (orders.length === 0) {
@ -209,12 +297,20 @@ export class OrderOrchestrator {
}
// Get order items for all orders in one query
const orderIds = orders.map(o => `'${o.Id}'`).join(",");
const rawOrderIds = orders
.map(order => order.Id)
.filter((id): id is string => typeof id === "string");
if (rawOrderIds.length === 0) {
return [];
}
const orderIdsClause = buildInClause(rawOrderIds, "orderIds");
const itemsSoql = `
SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice,
${getOrderItemProduct2Select()}
FROM OrderItem
WHERE OrderId IN (${orderIds})
WHERE OrderId IN (${orderIdsClause})
ORDER BY OrderId, CreatedDate ASC
`;
@ -223,34 +319,12 @@ export class OrderOrchestrator {
)) as SalesforceQueryResult<SalesforceOrderItemRecord>;
const allItems = itemsResult.records || [];
const itemsByOrder = allItems.reduce(
(acc, item) => {
const mapped = mapOrderItemForSummary(item, fields);
if (!acc[mapped.orderId]) acc[mapped.orderId] = [];
acc[mapped.orderId].push({
name: mapped.product.name,
sku: mapped.product.sku,
itemClass: mapped.product.itemClass,
quantity: mapped.quantity,
unitPrice: mapped.unitPrice,
totalPrice: mapped.totalPrice,
billingCycle: mapped.billingCycle,
});
return acc;
},
{} as Record<
string,
Array<{
name?: string;
sku?: string;
itemClass?: string;
quantity: number;
unitPrice?: number;
totalPrice?: number;
billingCycle?: string;
}>
>
);
const itemsByOrder = allItems.reduce<Record<string, OrderItemSummary[]>>((acc, record) => {
const details = mapOrderItemRecord(record);
if (!acc[details.orderId]) acc[details.orderId] = [];
acc[details.orderId].push(toOrderItemSummary(details));
return acc;
}, {});
// Transform orders to domain types and return summary
return orders.map(order =>
@ -258,13 +332,13 @@ export class OrderOrchestrator {
id: order.Id,
orderNumber: order.OrderNumber,
status: order.Status,
orderType: (order as any).OrderType || order.Type,
orderType: getOrderStringField(order, "orderType") ?? order.Type,
effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount ?? 0,
createdDate: order.CreatedDate,
lastModifiedDate: order.LastModifiedDate,
whmcsOrderId: (order as any).WhmcsOrderId,
itemsSummary: itemsByOrder[order.Id] || [],
whmcsOrderId: getOrderStringField(order, "whmcsOrderId"),
itemsSummary: itemsByOrder[order.Id] ?? [],
})
);
} catch (error) {

View File

@ -0,0 +1,128 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { getStringField } from "@bff/modules/catalog/utils/salesforce-product.mapper";
import type {
SalesforcePricebookEntryRecord,
SalesforceProduct2Record,
SalesforceQueryResult,
} from "@customer-portal/domain";
import {
assertSalesforceId,
buildInClause,
sanitizeSoqlLiteral,
} from "@bff/integrations/salesforce/utils/soql.util";
interface PricebookProductMeta {
sku: string;
pricebookEntryId: string;
product2Id?: string;
unitPrice?: number;
itemClass?: string;
internetOfferingType?: string;
internetPlanTier?: string;
vpnRegion?: string;
}
@Injectable()
export class OrderPricebookService {
private readonly chunkSize = 100;
constructor(
private readonly sf: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
async findPortalPricebookId(): Promise<string> {
const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal";
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${sanitizeSoqlLiteral(name)}%' LIMIT 1`;
try {
const result = (await this.sf.query(soql)) as SalesforceQueryResult<{ Id?: string }>;
if (result.records?.length) {
const resolved = result.records[0]?.Id;
if (resolved) {
return assertSalesforceId(resolved, "pricebookId");
}
}
const std = (await this.sf.query(
"SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1"
)) as SalesforceQueryResult<{ Id?: string }>;
const pricebookId = std.records?.[0]?.Id;
if (!pricebookId) {
this.logger.error({ soql }, "No active pricebook found");
throw new NotFoundException("No active pricebook found");
}
return assertSalesforceId(pricebookId, "pricebookId");
} catch (error) {
this.logger.error({ error, soql }, "Failed to find pricebook");
throw new NotFoundException("Failed to find pricebook");
}
}
async fetchProductMeta(
pricebookId: string,
skus: string[]
): Promise<Map<string, PricebookProductMeta>> {
const safePricebookId = assertSalesforceId(pricebookId, "pricebookId");
const uniqueSkus = Array.from(
new Set(skus.map(sku => sku?.trim()).filter((sku): sku is string => Boolean(sku)))
);
if (uniqueSkus.length === 0) {
return new Map();
}
const fields = getSalesforceFieldMap();
const meta = new Map<string, PricebookProductMeta>();
for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) {
const slice = uniqueSkus.slice(i, i + this.chunkSize);
const whereIn = buildInClause(slice, "skus");
const soql =
`SELECT Id, Product2Id, UnitPrice, ` +
`Product2.${fields.product.sku}, ` +
`Product2.${fields.product.itemClass}, ` +
`Product2.${fields.product.internetOfferingType}, ` +
`Product2.${fields.product.internetPlanTier}, ` +
`Product2.${fields.product.vpnRegion} ` +
`FROM PricebookEntry ` +
`WHERE Pricebook2Id='${safePricebookId}' AND IsActive=true AND Product2.${fields.product.sku} IN (${whereIn})`;
try {
const res = (await this.sf.query(soql)) as SalesforceQueryResult<
SalesforcePricebookEntryRecord & { Product2?: SalesforceProduct2Record | null }
>;
for (const record of res.records ?? []) {
const product = record.Product2 ?? undefined;
const sku = product ? getStringField(product, "sku") : undefined;
if (!sku) continue;
const normalizedSku = sku.trim().toUpperCase();
meta.set(normalizedSku, {
sku,
pricebookEntryId: assertSalesforceId(record.Id ?? undefined, "pricebookEntryId"),
product2Id: record.Product2Id ?? undefined,
unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined,
itemClass: product ? getStringField(product, "itemClass") : undefined,
internetOfferingType: product
? getStringField(product, "internetOfferingType")
: undefined,
internetPlanTier: product ? getStringField(product, "internetPlanTier") : undefined,
vpnRegion: product ? getStringField(product, "vpnRegion") : undefined,
});
}
} catch (error) {
this.logger.error({ error, slice }, "Failed to fetch pricebook entries by SKU");
throw error;
}
}
return meta;
}
}

View File

@ -2,10 +2,14 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain";
import {
createOrderRequestSchema,
orderBusinessValidationSchema,
type CreateOrderRequest,
type OrderBusinessValidation,
} from "@customer-portal/domain";
import { OrderPricebookService } from "./order-pricebook.service";
/**
* Handles all order validation logic - both format and business rules
@ -16,7 +20,7 @@ export class OrderValidator {
@Inject(Logger) private readonly logger: Logger,
private readonly mappings: MappingsService,
private readonly whmcs: WhmcsConnectionService,
private readonly sf: SalesforceConnection
private readonly pricebookService: OrderPricebookService
) {}
/**
@ -35,30 +39,24 @@ export class OrderValidator {
// Use direct Zod validation - simple and clean
const validationResult = createOrderRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
const errorMessages = validationResult.error.issues.map(issue => {
const path = issue.path.join('.');
const path = issue.path.join(".");
return path ? `${path}: ${issue.message}` : issue.message;
});
this.logger.error(
{
errors: errorMessages,
rawBody: JSON.stringify(rawBody, null, 2)
},
"Zod validation failed"
);
this.logger.error({ errors: errorMessages.length }, "Zod validation failed");
throw new BadRequestException({
message: 'Order validation failed',
message: "Order validation failed",
errors: errorMessages,
statusCode: 400,
});
}
const validatedData = validationResult.data;
// Return validated data directly (Zod ensures type safety)
const validatedBody: CreateOrderRequest = validatedData;
@ -70,7 +68,7 @@ export class OrderValidator {
},
"Zod request format validation passed"
);
return validatedBody;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@ -82,9 +80,11 @@ export class OrderValidator {
/**
* Validate user mapping exists - simple business logic
*/
async validateUserMapping(userId: string): Promise<{ userId: string; sfAccountId?: string; whmcsClientId: number }> {
async validateUserMapping(
userId: string
): Promise<{ userId: string; sfAccountId?: string; whmcsClientId: number }> {
const mapping = await this.mappings.findByUserId(userId);
if (!mapping) {
this.logger.warn({ userId }, "User mapping not found");
throw new BadRequestException("User account mapping is required before ordering");
@ -113,10 +113,9 @@ export class OrderValidator {
throw new BadRequestException("A payment method is required before ordering");
}
} catch (e) {
this.logger.warn(
{ err: getErrorMessage(e) },
"Payment method check soft-failed; proceeding cautiously"
);
const err = getErrorMessage(e);
this.logger.error({ err }, "Payment method verification failed");
throw new BadRequestException("Unable to verify payment method. Please try again later.");
}
}
@ -138,7 +137,11 @@ export class OrderValidator {
throw new BadRequestException("An Internet service already exists for this account");
}
} catch (e) {
this.logger.warn({ err: getErrorMessage(e) }, "Internet duplicate check soft-failed");
const err = getErrorMessage(e);
this.logger.error({ err }, "Internet duplicate check failed");
throw new BadRequestException(
"Unable to verify existing Internet services. Please try again."
);
}
}
@ -147,22 +150,15 @@ export class OrderValidator {
*/
async validateSKUs(skus: string[], pricebookId: string): Promise<void> {
const invalidSKUs: string[] = [];
const fields = getSalesforceFieldMap();
const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus);
const normalizedSkus = skus
.map(sku => sku?.trim())
.filter((sku): sku is string => Boolean(sku))
.map(sku => ({ raw: sku, normalized: sku.toUpperCase() }));
for (const sku of skus) {
if (!sku) continue;
const safeSku = sku.replace(/'/g, "\\'");
const soql = `SELECT Id FROM PricebookEntry WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${fields.product.sku} = '${safeSku}' LIMIT 1`;
try {
const res = (await this.sf.query(soql)) as { records?: Array<{ Id?: string }> };
if (!res.records?.[0]?.Id) {
invalidSKUs.push(sku);
}
} catch (error) {
this.logger.error({ error, sku }, "Failed to validate SKU");
invalidSKUs.push(sku);
for (const sku of normalizedSkus) {
if (!meta.has(sku.normalized)) {
invalidSKUs.push(sku.raw);
}
}
@ -229,37 +225,6 @@ export class OrderValidator {
}
}
/**
* Find Portal pricebook ID
*/
async findPortalPricebookId(): Promise<string> {
const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal";
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`;
try {
const result = (await this.sf.query(soql)) as { records?: Array<{ Id?: string }> };
if (result.records?.length) {
return result.records[0].Id || "";
}
// fallback to Standard Price Book
const std = (await this.sf.query(
"SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1"
)) as { records?: Array<{ Id?: string }> };
const pricebookId = std.records?.[0]?.Id;
if (!pricebookId) {
this.logger.error({ soql }, "No active pricebook found");
throw new BadRequestException("No active pricebook found");
}
return pricebookId;
} catch (error) {
this.logger.error({ error, soql }, "Failed to find pricebook");
throw new BadRequestException("Failed to find pricebook");
}
}
/**
* Complete order validation - performs ALL validation (format + business)
*/
@ -267,39 +232,44 @@ export class OrderValidator {
userId: string,
rawBody: unknown
): Promise<{
validatedBody: CreateOrderRequest;
validatedBody: OrderBusinessValidation;
userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number };
pricebookId: string;
}> {
this.logger.log({ userId, rawBody }, "Starting complete order validation");
this.logger.log({ userId }, "Starting complete order validation");
// 1. Format validation (replaces DTO validation)
const validatedBody = this.validateRequestFormat(rawBody);
// 1b. Business validation (ensures userId-specific constraints)
const businessValidatedBody = orderBusinessValidationSchema.parse({
...validatedBody,
userId,
});
// 2. User and payment validation
const userMapping = await this.validateUserMapping(userId);
await this.validatePaymentMethod(userId, userMapping.whmcsClientId);
// 3. SKU validation
const pricebookId = await this.findPortalPricebookId();
await this.validateSKUs(validatedBody.skus, pricebookId);
this.validateBusinessRules(validatedBody.orderType, validatedBody.skus);
const pricebookId = await this.pricebookService.findPortalPricebookId();
await this.validateSKUs(businessValidatedBody.skus, pricebookId);
this.validateBusinessRules(businessValidatedBody.orderType, businessValidatedBody.skus);
// 4. Order-specific business validation
if (validatedBody.orderType === "Internet") {
if (businessValidatedBody.orderType === "Internet") {
await this.validateInternetDuplication(userId, userMapping.whmcsClientId);
}
this.logger.log(
{
userId,
orderType: validatedBody.orderType,
skuCount: validatedBody.skus.length,
orderType: businessValidatedBody.orderType,
skuCount: businessValidatedBody.skus.length,
},
"Complete order validation passed"
);
return { validatedBody, userMapping, pricebookId };
return { validatedBody: businessValidatedBody, userMapping, pricebookId };
}
}

View File

@ -2,7 +2,7 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsOrderItem } from "@bff/integrations/whmcs/services/whmcs-order.service";
import type { OrderItemDto } from "../types/order-details.dto";
import type { OrderDetailsResponse } from "@customer-portal/domain";
export interface OrderItemMappingResult {
whmcsItems: WhmcsOrderItem[];
@ -24,7 +24,7 @@ export class OrderWhmcsMapper {
/**
* Map Salesforce OrderItems to WHMCS format for provisioning
*/
mapOrderItemsToWhmcs(orderItems: OrderItemDto[]): OrderItemMappingResult {
mapOrderItemsToWhmcs(orderItems: OrderDetailsResponse["items"]): OrderItemMappingResult {
this.logger.log("Starting OrderItems mapping to WHMCS", {
itemCount: orderItems.length,
});
@ -79,7 +79,10 @@ export class OrderWhmcsMapper {
/**
* Map a single Salesforce OrderItem to WHMCS format
*/
private mapSingleOrderItem(item: OrderItemDto, index: number): WhmcsOrderItem {
private mapSingleOrderItem(
item: OrderDetailsResponse["items"][number],
index: number
): WhmcsOrderItem {
const product = item.product; // This is the transformed structure from OrderOrchestrator
if (!product) {
@ -92,10 +95,14 @@ export class OrderWhmcsMapper {
);
}
if (!product.billingCycle) {
throw new BadRequestException(`Product ${product.id} missing billing cycle`);
}
// Build WHMCS item - WHMCS products already have their billing cycles configured
const whmcsItem: WhmcsOrderItem = {
productId: product.whmcsProductId,
billingCycle: product.billingCycle.toLowerCase(), // Use the billing cycle from Salesforce OrderItem
billingCycle: product.billingCycle.toLowerCase(),
quantity: item.quantity || 1,
};

View File

@ -1,11 +1,10 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebititService } from "@bff/integrations/freebit/freebit.service";
import { OrderDetailsDto } from "../types/order-details.dto";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import type { OrderDetailsResponse } from "@customer-portal/domain";
export interface SimFulfillmentRequest {
orderDetails: OrderDetailsDto;
orderDetails: OrderDetailsResponse;
configurations: Record<string, unknown>;
}
@ -16,9 +15,6 @@ export class SimFulfillmentService {
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Handle SIM-specific fulfillment after WHMCs provisioning
*/
async fulfillSimOrder(request: SimFulfillmentRequest): Promise<void> {
const { orderDetails, configurations } = request;
@ -27,16 +23,13 @@ export class SimFulfillmentService {
orderType: orderDetails.orderType,
});
// Extract SIM-specific configurations
const simType = configurations.simType as "eSIM" | "Physical SIM" | undefined;
const eid = configurations.eid as string | undefined;
const activationType = configurations.activationType as "Immediate" | "Scheduled" | undefined;
const scheduledAt = configurations.scheduledAt as string | undefined;
const phoneNumber = configurations.mnpPhone as string | undefined;
const mnp = this.extractMnpConfig(configurations);
const addons = this.extractAddonConfig(configurations);
// Find the main SIM plan from order items
const simPlanItem = orderDetails.items.find(
item => item.product.itemClass === "Plan" || item.product.sku?.toLowerCase().includes("sim")
);
@ -50,7 +43,6 @@ export class SimFulfillmentService {
throw new Error("SIM plan SKU not found");
}
// Validate required fields
if (simType === "eSIM" && (!eid || eid.length < 15)) {
throw new Error("EID is required for eSIM and must be valid");
}
@ -59,7 +51,6 @@ export class SimFulfillmentService {
throw new Error("Phone number is required for SIM activation");
}
// Perform Freebit activation
await this.activateSim({
account: phoneNumber,
eid,
@ -68,7 +59,6 @@ export class SimFulfillmentService {
activationType: activationType || "Immediate",
scheduledAt,
mnp,
addons,
});
this.logger.log("SIM fulfillment completed successfully", {
@ -78,9 +68,6 @@ export class SimFulfillmentService {
});
}
/**
* Activate SIM via Freebit API
*/
private async activateSim(params: {
account: string;
eid?: string;
@ -99,15 +86,10 @@ export class SimFulfillmentService {
gender?: string;
birthday?: string;
};
addons?: {
voiceMail?: boolean;
callWaiting?: boolean;
};
}): Promise<void> {
const { account, eid, planSku, simType, activationType, scheduledAt, mnp, addons } = params;
const { account, eid, planSku, simType, activationType, scheduledAt, mnp } = params;
try {
// Activate eSIM if applicable
if (simType === "eSIM") {
await this.freebit.activateEsimAccountNew({
account,
@ -144,20 +126,6 @@ export class SimFulfillmentService {
account,
});
}
// Apply add-ons (voice options) if selected
if (addons && (addons.voiceMail || addons.callWaiting)) {
await this.freebit.updateSimFeatures(account, {
voiceMailEnabled: !!addons.voiceMail,
callWaitingEnabled: !!addons.callWaiting,
});
this.logger.log("SIM add-ons applied", {
account,
voiceMail: addons.voiceMail,
callWaiting: addons.callWaiting,
});
}
} catch (error) {
this.logger.error("SIM activation failed", {
account,
@ -168,12 +136,9 @@ export class SimFulfillmentService {
}
}
/**
* Extract MNP configuration from order configurations
*/
private extractMnpConfig(configurations: Record<string, unknown>) {
const isMnp = configurations.isMnp;
if (!isMnp || isMnp !== "true") {
if (isMnp !== "true") {
return undefined;
}
@ -189,14 +154,4 @@ export class SimFulfillmentService {
birthday: configurations.portingDateOfBirth as string | undefined,
};
}
/**
* Extract addon configuration from order configurations
*/
private extractAddonConfig(configurations: Record<string, unknown>) {
// Check if voice addons are present in the configurations
// This would need to be determined based on the order items or configurations
// For now, return undefined - this can be enhanced based on actual addon detection logic
return undefined;
}
}

View File

@ -4,7 +4,11 @@ import { FreebititService } from "@bff/integrations/freebit/freebit.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SubscriptionsService } from "./subscriptions.service";
import { SimDetails, SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types";
import {
SimDetails,
SimUsage,
SimTopUpHistory,
} from "@bff/integrations/freebit/interfaces/freebit.types";
import { SimUsageStoreService } from "./sim-usage-store.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { EmailService } from "@bff/infra/email/email.service";

View File

@ -23,9 +23,9 @@ import {
import { SubscriptionsService } from "./subscriptions.service";
import { SimManagementService } from "./sim-management.service";
import {
Subscription,
SubscriptionList,
import {
Subscription,
SubscriptionList,
InvoiceList,
simTopupRequestSchema,
simChangePlanRequestSchema,
@ -34,7 +34,7 @@ import {
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
type SimFeaturesRequest
type SimFeaturesRequest,
} from "@customer-portal/domain";
import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";

View File

@ -1,10 +1,20 @@
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain";
import {
Subscription,
SubscriptionList,
InvoiceList,
invoiceListSchema,
} from "@customer-portal/domain";
import type { Invoice, InvoiceItem } from "@customer-portal/domain";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { Logger } from "nestjs-pino";
import { z } from "zod";
import {
subscriptionSchema,
type SubscriptionSchema,
} from "@customer-portal/domain/validation/shared/entities";
export interface GetSubscriptionsOptions {
status?: string;
@ -28,28 +38,36 @@ export class SubscriptionsService {
const { status } = options;
try {
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Fetch subscriptions from WHMCS
const subscriptionList = await this.whmcsService.getSubscriptions(
mapping.whmcsClientId,
userId,
{ status }
);
this.logger.log(
`Retrieved ${subscriptionList.subscriptions.length} subscriptions for user ${userId}`,
{
status,
totalCount: subscriptionList.totalCount,
}
);
const parsed = z
.object({
subscriptions: z.array(subscriptionSchema),
totalCount: z.number(),
})
.safeParse(subscriptionList);
return subscriptionList;
if (!parsed.success) {
throw new Error(parsed.error.message);
}
const filtered = status
? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase())
: parsed.data.subscriptions;
return {
subscriptions: filtered,
totalCount: filtered.length,
} satisfies SubscriptionList;
} catch (error) {
this.logger.error(`Failed to get subscriptions for user ${userId}`, {
error: getErrorMessage(error),
@ -355,12 +373,14 @@ export class SubscriptionsService {
// Get all invoices for the user WITH ITEMS (needed for subscription linking)
// TODO: Consider implementing server-side filtering in WHMCS service to improve performance
const allInvoices = await this.whmcsService.getInvoicesWithItems(
const invoicesResponse = await this.whmcsService.getInvoicesWithItems(
mapping.whmcsClientId,
userId,
{ page: 1, limit: 1000 } // Get more to filter locally
);
const allInvoices = invoicesResponse;
// Filter invoices that have items related to this subscription
// Note: subscriptionId is the same as serviceId in our current WHMCS mapping
this.logger.debug(
@ -487,4 +507,29 @@ export class SubscriptionsService {
};
}
}
private async checkUserHasExistingSim(userId: string): Promise<boolean> {
try {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
return false;
}
const products = await this.whmcsService.getClientsProducts({
clientid: mapping.whmcsClientId,
});
const services = products?.products?.product ?? [];
return services.some(
service =>
typeof service.groupname === "string" &&
service.groupname.toLowerCase().includes("sim") &&
typeof service.status === "string" &&
service.status.toLowerCase() === "active"
);
} catch (error) {
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
return false;
}
}
}

View File

@ -11,11 +11,11 @@ import {
import { UsersService } from "./users.service";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import { ZodValidationPipe } from "@bff/core/validation";
import {
import {
updateProfileRequestSchema,
updateAddressRequestSchema,
type UpdateProfileRequest,
type UpdateAddressRequest
type UpdateAddressRequest,
} from "@customer-portal/domain";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";

View File

@ -24,7 +24,19 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
interface SalesforceAccount extends SalesforceAccountRecord {}
// Use a subset of PrismaUser for updates
type UserUpdateData = Partial<Pick<PrismaUser, 'firstName' | 'lastName' | 'company' | 'phone' | 'passwordHash' | 'failedLoginAttempts' | 'lastLoginAt' | 'lockedUntil'>>;
type UserUpdateData = Partial<
Pick<
PrismaUser,
| "firstName"
| "lastName"
| "company"
| "phone"
| "passwordHash"
| "failedLoginAttempts"
| "lastLoginAt"
| "lockedUntil"
>
>;
@Injectable()
export class UsersService {
@ -192,7 +204,7 @@ export class UsersService {
phone: phone || user.phone,
email: email || user.email,
};
return this.toDomainUser(enhancedUser);
}
@ -347,7 +359,7 @@ export class UsersService {
)
.sort(
(a: Invoice, b: Invoice) =>
new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime()
new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
);
if (upcomingInvoices.length > 0) {

View File

@ -31,6 +31,6 @@
"module": "CommonJS"
}
},
"include": ["src/**/*"],
"include": ["src/**/*", "scripts/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -5,7 +5,7 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { createClient } from "@/lib/api";
import { apiClient } from "@/lib/api";
import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling";
import logger from "@customer-portal/logging";
import type {
@ -17,10 +17,14 @@ import type {
} from "@customer-portal/domain";
import { authResponseSchema } from "@customer-portal/domain/validation";
// Create API client instance
const apiClient = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const withAuthHeaders = (accessToken?: string) =>
accessToken
? {
headers: {
Authorization: `Bearer ${accessToken}`,
} as Record<string, string>,
}
: {};
interface AuthState {
// State
@ -67,12 +71,8 @@ export const useAuthStore = create<AuthState>()(
login: async (credentials: LoginRequest) => {
set({ loading: true, error: null });
try {
// Configure API client with no auth for login
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/login', { body: credentials });
// Use shared API client with consistent configuration
const response = await apiClient.POST('/auth/login', { body: credentials });
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Login failed');
@ -100,11 +100,7 @@ export const useAuthStore = create<AuthState>()(
signup: async (data: SignupRequest) => {
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/signup', { body: data });
const response = await apiClient.POST('/auth/signup', { body: data });
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Signup failed');
@ -133,12 +129,9 @@ export const useAuthStore = create<AuthState>()(
try {
if (tokens?.accessToken) {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
getAuthHeader: () => `Bearer ${tokens.accessToken}`,
await apiClient.POST('/auth/logout', {
...withAuthHeaders(tokens.accessToken),
});
await client.POST('/api/auth/logout');
}
} catch (error) {
// Ignore logout errors - clear local state anyway
@ -156,14 +149,10 @@ export const useAuthStore = create<AuthState>()(
requestPasswordReset: async (email: string) => {
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/request-password-reset', {
const response = await apiClient.POST('/auth/request-password-reset', {
body: { email }
});
if (!response.data) {
throw new Error('Password reset request failed');
}
@ -181,11 +170,7 @@ export const useAuthStore = create<AuthState>()(
resetPassword: async (token: string, password: string) => {
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/reset-password', {
const response = await apiClient.POST('/auth/reset-password', {
body: { token, password }
});
const parsed = authResponseSchema.safeParse(response.data);
@ -217,15 +202,11 @@ export const useAuthStore = create<AuthState>()(
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
getAuthHeader: () => `Bearer ${tokens.accessToken}`,
});
const response = await client.POST('/api/auth/change-password', {
const response = await apiClient.POST('/auth/change-password', {
...withAuthHeaders(tokens.accessToken),
body: { currentPassword, newPassword }
});
if (!response.data) {
throw new Error('Password change failed');
}
@ -243,14 +224,10 @@ export const useAuthStore = create<AuthState>()(
checkPasswordNeeded: async (email: string) => {
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/check-password-needed', {
const response = await apiClient.POST('/auth/check-password-needed', {
body: { email }
});
if (!response.data) {
throw new Error('Check failed');
}
@ -269,11 +246,7 @@ export const useAuthStore = create<AuthState>()(
linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => {
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/link-whmcs', {
const response = await apiClient.POST('/auth/link-whmcs', {
body: { email, password }
});
@ -296,11 +269,7 @@ export const useAuthStore = create<AuthState>()(
setPassword: async (email: string, password: string) => {
set({ loading: true, error: null });
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/set-password', {
const response = await apiClient.POST('/auth/set-password', {
body: { email, password }
});
const parsed = authResponseSchema.safeParse(response.data);
@ -331,13 +300,10 @@ export const useAuthStore = create<AuthState>()(
if (!tokens?.accessToken) return;
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
getAuthHeader: () => `Bearer ${tokens.accessToken}`,
const response = await apiClient.GET('/me', {
...withAuthHeaders(tokens.accessToken),
});
const response = await client.GET('/api/me');
if (!response.data) {
// Token might be expired, try to refresh
await get().refreshTokens();
@ -361,11 +327,7 @@ export const useAuthStore = create<AuthState>()(
}
try {
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/refresh', {
const response = await apiClient.POST('/auth/refresh', {
body: {
refreshToken: tokens.refreshToken,
deviceId: localStorage.getItem('deviceId') || undefined,

View File

@ -4,6 +4,12 @@ import { ReactNode } from "react";
import { AnimatedCard } from "@/components/molecules";
import { StatusPill } from "@/components/atoms/status-pill";
import { WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon } from "@heroicons/react/24/outline";
import {
calculateOrderTotals,
deriveOrderStatusDescriptor,
getServiceCategory,
summarizePrimaryItem,
} from "@/features/orders/utils/order-presenters";
export interface OrderSummaryLike {
id: string | number;
@ -33,86 +39,45 @@ export interface OrderCardProps {
className?: string;
}
interface StatusInfo {
label: string;
description: string;
variant: "success" | "info" | "neutral";
}
const STATUS_PILL_VARIANT = {
success: "success",
info: "info",
warning: "warning",
neutral: "neutral",
} as const;
function getStatusInfo(status: string, activationStatus?: string): StatusInfo {
if (activationStatus === "Activated") {
return {
label: "Active",
description: "Your service is active and ready to use",
variant: "success",
};
}
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
description: "We're reviewing your order",
variant: "info",
};
}
if (activationStatus === "Activating") {
return {
label: "Setting Up",
description: "We're preparing your service",
variant: "info",
};
}
return {
label: status || "Processing",
description: "Order is being processed",
variant: "neutral",
};
}
const SERVICE_LABELS = {
internet: "Internet",
sim: "Mobile",
vpn: "VPN",
default: "Service",
} as const;
function getServiceTypeDisplay(orderType?: string): { icon: ReactNode; label: string } {
switch (orderType) {
case "Internet":
return { icon: <WifiIcon className="h-5 w-5" />, label: "Internet" };
case "SIM":
return { icon: <DevicePhoneMobileIcon className="h-5 w-5" />, label: "Mobile" };
case "VPN":
return { icon: <LockClosedIcon className="h-5 w-5" />, label: "VPN" };
const renderServiceIcon = (orderType?: string): ReactNode => {
const category = getServiceCategory(orderType);
switch (category) {
case "internet":
return <WifiIcon className="h-5 w-5" />;
case "sim":
return <DevicePhoneMobileIcon className="h-5 w-5" />;
case "vpn":
return <LockClosedIcon className="h-5 w-5" />;
default:
return { icon: <CubeIcon className="h-5 w-5" />, label: "Service" };
return <CubeIcon className="h-5 w-5" />;
}
}
function getServiceSummary(order: OrderSummaryLike): string {
if (order.itemsSummary && order.itemsSummary.length > 0) {
const mainItem = order.itemsSummary[0];
const additionalCount = order.itemsSummary.length - 1;
let summary = mainItem.name || "Service";
if (additionalCount > 0) summary += ` +${additionalCount} more`;
return summary;
}
return order.itemSummary || "Service package";
}
function calculateOrderTotals(order: OrderSummaryLike): { monthlyTotal: number; oneTimeTotal: number } {
let monthlyTotal = 0;
let oneTimeTotal = 0;
if (order.itemsSummary && order.itemsSummary.length > 0) {
order.itemsSummary.forEach(item => {
const totalPrice = item.totalPrice || 0;
const billingCycle = (item.billingCycle || "").toLowerCase();
if (billingCycle === "monthly") monthlyTotal += totalPrice;
else oneTimeTotal += totalPrice;
});
} else {
monthlyTotal = order.totalAmount || 0;
}
return { monthlyTotal, oneTimeTotal };
}
};
export function OrderCard({ order, onClick, footer, className }: OrderCardProps) {
const statusInfo = getStatusInfo(order.status, order.activationStatus);
const serviceType = getServiceTypeDisplay(order.orderType);
const serviceSummary = getServiceSummary(order);
const totals = calculateOrderTotals(order);
const statusDescriptor = deriveOrderStatusDescriptor({
status: order.status,
activationStatus: order.activationStatus,
});
const statusVariant = STATUS_PILL_VARIANT[statusDescriptor.tone];
const serviceIcon = renderServiceIcon(order.orderType);
const serviceCategory = getServiceCategory(order.orderType);
const serviceLabel = SERVICE_LABELS[serviceCategory];
const serviceSummary = summarizePrimaryItem(order.itemsSummary, order.itemSummary || "Service package");
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
return (
<AnimatedCard
@ -122,14 +87,14 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2 text-gray-700">
<span className="text-lg">{serviceType.icon}</span>
<span className="font-semibold">{serviceType.label}</span>
<span className="text-lg">{serviceIcon}</span>
<span className="font-semibold">{serviceLabel}</span>
<span className="text-gray-400"></span>
<span className="text-sm text-gray-500">
Order #{order.orderNumber || String(order.id).slice(-8)}
</span>
</div>
<StatusPill label={statusInfo.label} variant={statusInfo.variant} />
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
</div>
<div className="flex items-start justify-between gap-4">
@ -142,7 +107,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
})}
</p>
<p className="mt-1 font-medium text-gray-900">{serviceSummary}</p>
<p className="mt-0.5 text-sm text-gray-600">{statusInfo.description}</p>
<p className="mt-0.5 text-sm text-gray-600">{statusDescriptor.description}</p>
</div>
{(totals.monthlyTotal > 0 || totals.oneTimeTotal > 0) && (
<div className="text-right shrink-0">
@ -180,5 +145,3 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
}
export default OrderCard;

View File

@ -1,28 +1,31 @@
import { createClient } from "@/lib/api";
import type { CreateOrderRequest } from "@customer-portal/domain";
export interface CreateOrderRequest {
orderType: "Internet" | "SIM" | "VPN" | "Other";
skus: string[];
configurations?: Record<string, unknown>;
}
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000";
const getAuthHeader = (): string | undefined => {
if (typeof window === "undefined") return undefined;
const authStore = window.localStorage.getItem("auth-store");
if (!authStore) return undefined;
try {
const parsed = JSON.parse(authStore);
const token = parsed?.state?.tokens?.accessToken;
return token ? `Bearer ${token}` : undefined;
} catch {
return undefined;
}
};
const createAuthedClient = () =>
createClient({
baseUrl: API_BASE,
getAuthHeader,
});
async function createOrder<T = { sfOrderId: string }>(payload: CreateOrderRequest): Promise<T> {
const apiClient = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
getAuthHeader: () => {
const authStore = localStorage.getItem('auth-store');
if (authStore) {
try {
const parsed = JSON.parse(authStore);
const token = parsed?.state?.tokens?.accessToken;
return token ? `Bearer ${token}` : undefined;
} catch {
return undefined;
}
}
return undefined;
}
});
const apiClient = createAuthedClient();
const response = await apiClient.POST("/api/orders", { body: payload });
if (!response.data) {
throw new Error("Order creation failed");
@ -31,43 +34,13 @@ async function createOrder<T = { sfOrderId: string }>(payload: CreateOrderReques
}
async function getMyOrders<T = unknown[]>(): Promise<T> {
const apiClient = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
getAuthHeader: () => {
const authStore = localStorage.getItem('auth-store');
if (authStore) {
try {
const parsed = JSON.parse(authStore);
const token = parsed?.state?.tokens?.accessToken;
return token ? `Bearer ${token}` : undefined;
} catch {
return undefined;
}
}
return undefined;
}
});
const apiClient = createAuthedClient();
const response = await apiClient.GET("/api/orders/user");
return (response.data ?? []) as T;
}
async function getOrderById<T = unknown>(orderId: string): Promise<T> {
const apiClient = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
getAuthHeader: () => {
const authStore = localStorage.getItem('auth-store');
if (authStore) {
try {
const parsed = JSON.parse(authStore);
const token = parsed?.state?.tokens?.accessToken;
return token ? `Bearer ${token}` : undefined;
} catch {
return undefined;
}
}
return undefined;
}
});
const apiClient = createAuthedClient();
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
params: { path: { sfOrderId: orderId } },
});

View File

@ -0,0 +1,168 @@
export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default";
export type StatusTone = "success" | "info" | "warning" | "neutral";
export type OrderStatusState =
| "active"
| "review"
| "scheduled"
| "activating"
| "processing";
export interface OrderStatusDescriptor {
label: string;
state: OrderStatusState;
tone: StatusTone;
description: string;
nextAction?: string;
timeline?: string;
scheduledDate?: string;
}
interface StatusInput {
status: string;
activationStatus?: string;
activationType?: string;
scheduledAt?: string;
}
export function deriveOrderStatusDescriptor({
status,
activationStatus,
scheduledAt,
}: StatusInput): OrderStatusDescriptor {
if (activationStatus === "Activated") {
return {
label: "Service Active",
state: "active",
tone: "success",
description: "Your service is active and ready to use",
timeline: "Service activated successfully",
};
}
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
state: "review",
tone: "info",
description: "Our team is reviewing your order details",
nextAction: "We will contact you within 1 business day with next steps",
timeline: "Review typically takes 1 business day",
};
}
if (activationStatus === "Scheduled") {
const scheduledDate = formatScheduledDate(scheduledAt);
return {
label: "Installation Scheduled",
state: "scheduled",
tone: "warning",
description: "Your installation has been scheduled",
nextAction: scheduledDate
? `Installation scheduled for ${scheduledDate}`
: "Installation will be scheduled shortly",
timeline: "Please be available during the scheduled time",
scheduledDate,
};
}
if (activationStatus === "Activating") {
return {
label: "Setting Up Service",
state: "activating",
tone: "info",
description: "We're configuring your service",
nextAction: "Installation team will contact you to schedule",
timeline: "Setup typically takes 3-5 business days",
};
}
return {
label: status || "Processing",
state: "processing",
tone: "neutral",
description: "Your order is being processed",
timeline: "We will update you as progress is made",
};
}
export function getServiceCategory(orderType?: string): OrderServiceCategory {
switch (orderType) {
case "Internet":
return "internet";
case "SIM":
return "sim";
case "VPN":
return "vpn";
default:
return "default";
}
}
export function summarizePrimaryItem(
items: Array<{ name?: string; quantity?: number }> | undefined,
fallback: string
): string {
if (!items || items.length === 0) {
return fallback;
}
const [primary, ...rest] = items;
let summary = primary?.name || fallback;
const additionalCount = rest.filter(Boolean).length;
if (additionalCount > 0) {
summary += ` +${additionalCount} more`;
}
return summary;
}
export interface OrderTotals {
monthlyTotal: number;
oneTimeTotal: number;
}
export function calculateOrderTotals(
items: Array<{ totalPrice?: number; billingCycle?: string }> | undefined,
fallbackTotal?: number
): OrderTotals {
let monthlyTotal = 0;
let oneTimeTotal = 0;
if (items && items.length > 0) {
for (const item of items) {
const total = item.totalPrice ?? 0;
const billingCycle = normalizeBillingCycle(item.billingCycle);
if (billingCycle === "monthly") {
monthlyTotal += total;
} else if (billingCycle === "onetime") {
oneTimeTotal += total;
} else {
monthlyTotal += total;
}
}
} else if (typeof fallbackTotal === "number") {
monthlyTotal = fallbackTotal;
}
return { monthlyTotal, oneTimeTotal };
}
export function normalizeBillingCycle(value?: string): "monthly" | "onetime" | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "monthly") return "monthly";
if (normalized === "onetime" || normalized === "one-time") return "onetime";
return null;
}
export function formatScheduledDate(scheduledAt?: string): string | undefined {
if (!scheduledAt) return undefined;
const date = new Date(scheduledAt);
if (Number.isNaN(date.getTime())) return undefined;
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
}

View File

@ -14,6 +14,7 @@ import {
import { SubCard } from "@/components/molecules/SubCard";
import { StatusPill } from "@/components/atoms/status-pill";
import { ordersService } from "@/features/orders/services/orders.service";
import { calculateOrderTotals, deriveOrderStatusDescriptor, getServiceCategory } from "@/features/orders/utils/order-presenters";
interface OrderItem {
id: string;
@ -30,15 +31,6 @@ interface OrderItem {
};
}
interface StatusInfo {
label: string;
color: string;
bgColor: string;
description: string;
nextAction?: string;
timeline?: string;
}
interface OrderSummary {
id: string;
orderNumber?: string;
@ -56,90 +48,26 @@ interface OrderSummary {
items?: OrderItem[];
}
const getDetailedStatusInfo = (
status: string,
activationStatus?: string,
activationType?: string,
scheduledAt?: string
): StatusInfo => {
if (activationStatus === "Activated") {
return {
label: "Service Active",
color: "text-green-800",
bgColor: "bg-green-50 border-green-200",
description: "Your service is active and ready to use",
timeline: "Service activated successfully",
};
}
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
color: "text-blue-800",
bgColor: "bg-blue-50 border-blue-200",
description: "Our team is reviewing your order details",
nextAction: "We will contact you within 1 business day with next steps",
timeline: "Review typically takes 1 business day",
};
}
if (activationStatus === "Scheduled") {
const scheduledDate = scheduledAt
? new Date(scheduledAt).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})
: "soon";
return {
label: "Installation Scheduled",
color: "text-orange-800",
bgColor: "bg-orange-50 border-orange-200",
description: "Your installation has been scheduled",
nextAction: `Installation scheduled for ${scheduledDate}`,
timeline: "Please be available during the scheduled time",
};
}
if (activationStatus === "Activating") {
return {
label: "Setting Up Service",
color: "text-purple-800",
bgColor: "bg-purple-50 border-purple-200",
description: "We're configuring your service",
nextAction: "Installation team will contact you to schedule",
timeline: "Setup typically takes 3-5 business days",
};
}
return {
label: status || "Processing",
color: "text-gray-800",
bgColor: "bg-gray-50 border-gray-200",
description: "Your order is being processed",
timeline: "We will update you as progress is made",
};
const STATUS_PILL_VARIANT: Record<"success" | "info" | "warning" | "neutral", "success" | "info" | "warning" | "neutral"> = {
success: "success",
info: "info",
warning: "warning",
neutral: "neutral",
};
const getOrderTypeIcon = (orderType?: string) => {
switch (orderType) {
case "Internet":
return <WifiIcon className="h-6 w-6" />;
case "SIM":
return <DevicePhoneMobileIcon className="h-6 w-6" />;
case "VPN":
return <LockClosedIcon className="h-6 w-6" />;
const renderServiceIcon = (category: ReturnType<typeof getServiceCategory>, className: string) => {
switch (category) {
case "internet":
return <WifiIcon className={className} />;
case "sim":
return <DevicePhoneMobileIcon className={className} />;
case "vpn":
return <LockClosedIcon className={className} />;
default:
return <CubeIcon className="h-6 w-6" />;
return <CubeIcon className={className} />;
}
};
const calculateDetailedTotals = (items: OrderItem[]) => {
let monthlyTotal = 0;
let oneTimeTotal = 0;
items.forEach(item => {
if (item.product.billingCycle === "Monthly") monthlyTotal += item.totalPrice || 0;
else oneTimeTotal += item.totalPrice || 0;
});
return { monthlyTotal, oneTimeTotal };
};
export function OrderDetailContainer() {
const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
@ -147,6 +75,28 @@ export function OrderDetailContainer() {
const [error, setError] = useState<string | null>(null);
const isNewOrder = searchParams.get("status") === "success";
const statusDescriptor = data
? deriveOrderStatusDescriptor({
status: data.status,
activationStatus: data.activationStatus,
scheduledAt: data.scheduledAt,
})
: null;
const statusPillVariant = statusDescriptor
? STATUS_PILL_VARIANT[statusDescriptor.tone]
: STATUS_PILL_VARIANT.neutral;
const serviceCategory = getServiceCategory(data?.orderType);
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
const totals = calculateOrderTotals(
data?.items?.map(item => ({
totalPrice: item.totalPrice,
billingCycle: item.product?.billingCycle,
})),
data?.totalAmount
);
useEffect(() => {
let mounted = true;
const fetchStatus = async () => {
@ -205,58 +155,37 @@ export function OrderDetailContainer() {
</div>
</div>
)}
{data && (() => {
const statusInfo = getDetailedStatusInfo(
data.status,
data.activationStatus,
data.activationType,
data.scheduledAt
);
const statusVariant = statusInfo.label.includes("Active")
? "success"
: statusInfo.label.includes("Review") ||
statusInfo.label.includes("Setting Up") ||
statusInfo.label.includes("Scheduled")
? "info"
: "neutral";
return (
<SubCard
className="mb-9"
header={<h3 className="text-xl font-bold text-gray-900">Status</h3>}
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div className="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div>
<StatusPill
label={statusInfo.label}
variant={statusVariant as "info" | "success" | "warning" | "error"}
/>
</div>
{statusInfo.nextAction && (
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium text-blue-900">Next Steps</span>
</div>
<p className="text-sm text-blue-800">{statusInfo.nextAction}</p>
{data && statusDescriptor && (
<SubCard className="mb-9" header={<h3 className="text-xl font-bold text-gray-900">Status</h3>}>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div className="text-gray-700 text-lg sm:text-xl">{statusDescriptor.description}</div>
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
</div>
{statusDescriptor.nextAction && (
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium text-blue-900">Next Steps</span>
</div>
)}
{statusInfo.timeline && (
<div className="text-sm text-gray-500">{statusInfo.timeline}</div>
)}
</SubCard>
);
})()}
<p className="text-sm text-blue-800">{statusDescriptor.nextAction}</p>
</div>
)}
{statusDescriptor.timeline && (
<div className="text-sm text-gray-500">{statusDescriptor.timeline}</div>
)}
</SubCard>
)}
{data && (
<SubCard
header={
<div className="flex items-center gap-2">
{getOrderTypeIcon(data.orderType)}
{serviceIcon}
<h3 className="text-xl font-bold text-gray-900">Order Items</h3>
</div>
}
@ -283,25 +212,20 @@ export function OrderDetailContainer() {
</div>
</div>
))}
{(() => {
const totals = calculateDetailedTotals(data.items || []);
return (
<div className="flex items-center justify-end">
<div className="text-right">
<div className="text-xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()} {" "}
<span className="text-sm text-gray-500">/mo</span>
</div>
{totals.oneTimeTotal > 0 && (
<div className="text-sm text-orange-600 font-semibold">
¥{totals.oneTimeTotal.toLocaleString()} {" "}
<span className="text-xs text-gray-500">one-time</span>
</div>
)}
</div>
<div className="flex items-center justify-end">
<div className="text-right">
<div className="text-xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()} {" "}
<span className="text-sm text-gray-500">/mo</span>
</div>
);
})()}
{totals.oneTimeTotal > 0 && (
<div className="text-sm text-orange-600 font-semibold">
¥{totals.oneTimeTotal.toLocaleString()} {" "}
<span className="text-xs text-gray-500">one-time</span>
</div>
)}
</div>
</div>
</div>
)}
</SubCard>
@ -311,5 +235,3 @@ export function OrderDetailContainer() {
}
export default OrderDetailContainer;

View File

@ -3,15 +3,7 @@
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import {
ClipboardDocumentListIcon,
CheckCircleIcon,
WifiIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
CubeIcon,
} from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill";
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { ordersService } from "@/features/orders/services/orders.service";
@ -40,14 +32,6 @@ interface OrderSummary {
}>;
}
interface StatusInfo {
label: string;
color: string;
bgColor: string;
description: string;
nextAction?: string;
}
function OrdersSuccessBanner() {
const searchParams = useSearchParams();
const showSuccess = searchParams.get("status") === "success";
@ -90,81 +74,6 @@ export function OrdersListContainer() {
void fetchOrders();
}, []);
const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => {
if (activationStatus === "Activated") {
return {
label: "Active",
color: "text-green-800",
bgColor: "bg-green-100",
description: "Your service is active and ready to use",
};
}
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
color: "text-blue-800",
bgColor: "bg-blue-100",
description: "We're reviewing your order",
nextAction: "We'll contact you within 1 business day",
};
}
if (activationStatus === "Activating") {
return {
label: "Setting Up",
color: "text-orange-800",
bgColor: "bg-orange-100",
description: "We're preparing your service",
nextAction: "Installation will be scheduled soon",
};
}
return {
label: status || "Processing",
color: "text-gray-800",
bgColor: "bg-gray-100",
description: "Order is being processed",
};
};
const getServiceTypeDisplay = (orderType?: string) => {
switch (orderType) {
case "Internet":
return { icon: <WifiIcon className="h-6 w-6" />, label: "Internet Service" };
case "SIM":
return { icon: <DevicePhoneMobileIcon className="h-6 w-6" />, label: "Mobile Service" };
case "VPN":
return { icon: <LockClosedIcon className="h-6 w-6" />, label: "VPN Service" };
default:
return { icon: <CubeIcon className="h-6 w-6" />, label: "Service" };
}
};
const getServiceSummary = (order: OrderSummary) => {
if (order.itemsSummary && order.itemsSummary.length > 0) {
const mainItem = order.itemsSummary[0];
const additionalCount = order.itemsSummary.length - 1;
let summary = mainItem.name || "Service";
if (additionalCount > 0) summary += ` +${additionalCount} more`;
return summary;
}
return order.itemSummary || "Service package";
};
const calculateOrderTotals = (order: OrderSummary) => {
let monthlyTotal = 0;
let oneTimeTotal = 0;
if (order.itemsSummary && order.itemsSummary.length > 0) {
order.itemsSummary.forEach(item => {
const totalPrice = item.totalPrice || 0;
const billingCycle = item.billingCycle?.toLowerCase() || "";
if (billingCycle === "monthly") monthlyTotal += totalPrice;
else oneTimeTotal += totalPrice;
});
} else {
monthlyTotal = order.totalAmount || 0;
}
return { monthlyTotal, oneTimeTotal } as const;
};
return (
<PageLayout
icon={<ClipboardDocumentListIcon />}
@ -208,5 +117,3 @@ export function OrdersListContainer() {
}
export default OrdersListContainer;

View File

@ -5,6 +5,7 @@ export type {
AuthHeaderResolver,
CreateClientOptions,
} from "./runtime/client";
export { ApiError, isApiError } from "./runtime/client";
// Re-export response helpers
export * from "./response-helpers";

View File

@ -3,15 +3,17 @@
* Provides consistent error handling and user-friendly messages
*/
export interface ApiError {
import { ApiError as ClientApiError } from "@/lib/api";
export interface ApiErrorPayload {
success: false;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
timestamp: string;
path: string;
timestamp?: string;
path?: string;
}
export interface ApiErrorInfo {
@ -25,14 +27,11 @@ export interface ApiErrorInfo {
* Extract error information from various error types
*/
export function getErrorInfo(error: unknown): ApiErrorInfo {
// Handle API errors with structured format
if (isApiError(error)) {
return {
code: error.error.code,
message: error.error.message,
shouldLogout: shouldLogoutForError(error.error.code),
shouldRetry: shouldRetryForError(error.error.code),
};
if (error instanceof ClientApiError) {
const info = parseClientApiError(error);
if (info) {
return info;
}
}
// Handle fetch/network errors
@ -68,16 +67,14 @@ export function getErrorInfo(error: unknown): ApiErrorInfo {
};
}
export function isApiError(error: unknown): error is ApiError {
export function isApiErrorPayload(error: unknown): error is ApiErrorPayload {
return (
typeof error === 'object' &&
error !== null &&
'success' in error &&
error.success === false &&
(error as { success?: unknown }).success === false &&
'error' in error &&
typeof (error as any).error === 'object' &&
'code' in (error as any).error &&
'message' in (error as any).error
typeof (error as { error?: unknown }).error === 'object'
);
}
@ -149,3 +146,129 @@ export function createErrorLog(error: unknown, context: string): {
timestamp: new Date().toISOString(),
};
}
function parseClientApiError(error: ClientApiError): ApiErrorInfo | null {
const status = error.response?.status;
const parsedBody = parseRawErrorBody(error.body);
const payloadInfo = parsedBody
? deriveInfoFromPayload(parsedBody, status)
: null;
if (payloadInfo) {
return payloadInfo;
}
return {
code: status ? httpStatusCodeToLabel(status) : 'API_ERROR',
message: error.message,
shouldLogout: status === 401,
shouldRetry: typeof status === 'number' ? status >= 500 : true,
};
}
function parseRawErrorBody(body: unknown): unknown {
if (!body) {
return null;
}
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return body;
}
}
return body;
}
function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null {
if (isApiErrorPayload(payload)) {
const code = payload.error.code;
return {
code,
message: payload.error.message,
shouldLogout: shouldLogoutForError(code) || status === 401,
shouldRetry: shouldRetryForError(code),
};
}
if (isGlobalErrorPayload(payload)) {
const code = payload.code || payload.error || httpStatusCodeToLabel(status);
const message = payload.message || 'Request failed. Please try again.';
const derivedStatus = payload.statusCode ?? status;
return {
code,
message,
shouldLogout: shouldLogoutForError(code) || derivedStatus === 401,
shouldRetry:
typeof derivedStatus === 'number'
? derivedStatus >= 500
: shouldRetryForError(code),
};
}
if (
typeof payload === 'object' &&
payload !== null &&
'message' in payload &&
typeof (payload as { message?: unknown }).message === 'string'
) {
const code = typeof (payload as { code?: unknown }).code === 'string'
? (payload as { code: string }).code
: httpStatusCodeToLabel(status);
return {
code,
message: (payload as { message: string }).message,
shouldLogout: shouldLogoutForError(code) || status === 401,
shouldRetry:
typeof status === 'number'
? status >= 500
: shouldRetryForError(code),
};
}
return null;
}
function isGlobalErrorPayload(payload: unknown): payload is {
success: false;
code?: string;
message?: string;
error?: string;
statusCode?: number;
} {
return (
typeof payload === 'object' &&
payload !== null &&
'success' in payload &&
(payload as { success?: unknown }).success === false &&
('code' in payload || 'message' in payload || 'error' in payload)
);
}
function httpStatusCodeToLabel(status?: number): string {
if (!status) {
return 'API_ERROR';
}
switch (status) {
case 400:
return 'BAD_REQUEST';
case 401:
return 'UNAUTHORIZED';
case 403:
return 'FORBIDDEN';
case 404:
return 'NOT_FOUND';
case 409:
return 'CONFLICT';
case 422:
return 'UNPROCESSABLE_ENTITY';
default:
return status >= 500 ? 'SERVER_ERROR' : `HTTP_${status}`;
}
}

View File

@ -53,6 +53,35 @@ export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase {
Product2?: SalesforceProduct2Record | null;
}
export interface SalesforceProduct2WithPricebookEntries
extends SalesforceProduct2Record {
PricebookEntries?: {
records?: SalesforcePricebookEntryRecord[];
};
}
export interface SalesforceProductFieldMap {
sku: string;
portalCategory: string;
portalCatalog: string;
portalAccessible: string;
itemClass: string;
billingCycle: string;
whmcsProductId: string;
whmcsProductName: string;
internetPlanTier: string;
internetOfferingType: string;
displayOrder: string;
bundledAddon: string;
isBundledAddon: string;
simDataSize: string;
simPlanType: string;
simHasFamilyDiscount: string;
vpnRegion: string;
featureList?: string;
featureSet?: string;
}
export interface SalesforceAccountRecord extends SalesforceSObjectBase {
Name?: string;
SF_Account_No__c?: string | null;
@ -114,4 +143,3 @@ export interface SalesforceContactRecord extends SalesforceSObjectBase {
Phone?: string | null;
}

View File

@ -1,42 +1,9 @@
// Invoice types from WHMCS
import type { InvoiceStatus } from "../enums/status";
import type { WhmcsEntity } from "../common";
export interface Invoice extends WhmcsEntity {
number: string;
status: InvoiceStatus;
currency: string; // e.g. 'USD', 'JPY'
currencySymbol?: string; // e.g. '¥', '¥'
total: number; // decimal as number
subtotal: number;
tax: number;
issuedAt?: string; // ISO
dueDate?: string; // ISO
paidDate?: string; // ISO
pdfUrl?: string; // via /sso-link
paymentUrl?: string; // via /sso-link
description?: string;
items?: InvoiceItem[];
}
export interface InvoiceItem {
id: number;
description: string;
amount: number;
quantity?: number;
type: string;
serviceId?: number; // Links to the related service/product via WHMCS relid field
}
export interface InvoiceList {
invoices: Invoice[];
pagination: {
page: number;
totalPages: number;
totalItems: number;
nextCursor?: string;
};
}
export type {
InvoiceSchema as Invoice,
InvoiceItemSchema as InvoiceItem,
InvoiceListSchema as InvoiceList,
} from "../validation";
export interface InvoiceSsoLink {
url: string;

View File

@ -1,5 +1,6 @@
// User and authentication types
import type { BaseEntity, Address, IsoDateTimeString } from "../common";
import type { InvoiceList } from "./invoice";
export interface User extends BaseEntity {
email: string;
@ -21,12 +22,7 @@ export interface UserSummary {
totalSpent: number;
currency: string;
};
nextInvoice?: {
id: number;
dueDate: string;
amount: number;
currency: string;
};
nextInvoice?: InvoiceList["invoices"][number];
recentActivity: Activity[];
}

View File

@ -26,6 +26,3 @@ export type AuthTokensSchema = AuthResponse["tokens"];
export { orderDetailsSchema, orderSummarySchema };
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
export { orderDetailsSchema, orderSummarySchema };
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;

View File

@ -3,6 +3,8 @@
* Frontend form schemas that extend API request schemas with UI-specific fields
*/
import { z } from "zod";
import {
loginRequestSchema,
signupRequestSchema,

View File

@ -78,6 +78,8 @@ export {
authResponseSchema,
type AuthResponse,
type AuthTokensSchema,
type OrderDetailsResponse,
type OrderSummaryResponse,
} from "./api/responses";
export {

View File

@ -176,6 +176,16 @@ export const invoiceSchema = whmcsEntitySchema.extend({
items: z.array(invoiceItemSchema).optional(),
});
export const invoiceListSchema = z.object({
invoices: z.array(invoiceSchema),
pagination: z.object({
page: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative(),
totalItems: z.number().int().nonnegative(),
nextCursor: z.string().optional(),
}),
});
// =====================================================
// SUBSCRIPTION ENTITIES (WHMCS)
// =====================================================
@ -279,6 +289,7 @@ export type WhmcsOrderItemSchema = z.infer<typeof whmcsOrderItemSchema>;
export type WhmcsOrderSchema = z.infer<typeof whmcsOrderSchema>;
export type InvoiceItemSchema = z.infer<typeof invoiceItemSchema>;
export type InvoiceSchema = z.infer<typeof invoiceSchema>;
export type InvoiceListSchema = z.infer<typeof invoiceListSchema>;
export type SubscriptionSchema = z.infer<typeof subscriptionSchema>;
export type PaymentMethodSchema = z.infer<typeof paymentMethodSchema>;
export type PaymentSchema = z.infer<typeof paymentSchema>;