diff --git a/apps/bff/scripts/generate-openapi.ts b/apps/bff/scripts/generate-openapi.ts index 3ec1abff..7ed3520d 100644 --- a/apps/bff/scripts/generate-openapi.ts +++ b/apps/bff/scripts/generate-openapi.ts @@ -22,5 +22,3 @@ async function generate() { } void generate(); - - diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 541b18d1..17849fef 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -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 { // 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 { // 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`); diff --git a/apps/bff/src/core/config/app.config.ts b/apps/bff/src/core/config/app.config.ts index f9247585..0eda1ec3 100644 --- a/apps/bff/src/core/config/app.config.ts +++ b/apps/bff/src/core/config/app.config.ts @@ -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, }; - - diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 8ab33293..4295a69a 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -82,5 +82,3 @@ export function validate(config: Record): Record `PricebookEntry.Product2.${f}`).join(", "); } - diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index 37c97a4c..0c004f7e 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -21,5 +21,3 @@ export const apiRoutes: Routes = [ ], }, ]; - - diff --git a/apps/bff/src/core/config/throttler.config.ts b/apps/bff/src/core/config/throttler.config.ts index 16e8a471..7823b9de 100644 --- a/apps/bff/src/core/config/throttler.config.ts +++ b/apps/bff/src/core/config/throttler.config.ts @@ -12,5 +12,3 @@ export const createThrottlerConfig = (configService: ConfigService): ThrottlerMo limit: configService.get("AUTH_RATE_LIMIT_LIMIT", 3), }, ]; - - diff --git a/apps/bff/src/core/http/auth-error.filter.ts b/apps/bff/src/core/http/auth-error.filter.ts index 63887c93..2eda5944 100644 --- a/apps/bff/src/core/http/auth-error.filter.ts +++ b/apps/bff/src/core/http/auth-error.filter.ts @@ -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(); 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"; } } diff --git a/apps/bff/src/core/http/http-exception.filter.ts b/apps/bff/src/core/http/http-exception.filter.ts index 4c39398e..6f981060 100644 --- a/apps/bff/src/core/http/http-exception.filter.ts +++ b/apps/bff/src/core/http/http-exception.filter.ts @@ -81,5 +81,3 @@ export class GlobalExceptionFilter implements ExceptionFilter { response.status(status).json(errorResponse); } } - - diff --git a/apps/bff/src/core/http/success-response.interceptor.ts b/apps/bff/src/core/http/success-response.interceptor.ts index 18a57d36..cb0ff5c3 100644 --- a/apps/bff/src/core/http/success-response.interceptor.ts +++ b/apps/bff/src/core/http/success-response.interceptor.ts @@ -1,3 +1 @@ -export {} - - +export {}; diff --git a/apps/bff/src/core/logging/logging.module.ts b/apps/bff/src/core/logging/logging.module.ts index 7344fb44..754b999e 100644 --- a/apps/bff/src/core/logging/logging.module.ts +++ b/apps/bff/src/core/logging/logging.module.ts @@ -67,5 +67,3 @@ import { LoggerModule } from "nestjs-pino"; exports: [LoggerModule], }) export class LoggingModule {} - - diff --git a/apps/bff/src/core/utils/error.util.ts b/apps/bff/src/core/utils/error.util.ts index 43d0eda2..b3339424 100644 --- a/apps/bff/src/core/utils/error.util.ts +++ b/apps/bff/src/core/utils/error.util.ts @@ -200,5 +200,3 @@ export async function safeAsync( return { data: fallback ?? null, error }; } } - - diff --git a/apps/bff/src/core/utils/validation.util.ts b/apps/bff/src/core/utils/validation.util.ts index 5ba7008d..45c6b138 100644 --- a/apps/bff/src/core/utils/validation.util.ts +++ b/apps/bff/src/core/utils/validation.util.ts @@ -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"); } } - - diff --git a/apps/bff/src/core/validation/index.ts b/apps/bff/src/core/validation/index.ts index 8568b257..f11a7756 100644 --- a/apps/bff/src/core/validation/index.ts +++ b/apps/bff/src/core/validation/index.ts @@ -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, }); } diff --git a/apps/bff/src/infra/audit/audit.module.ts b/apps/bff/src/infra/audit/audit.module.ts index e8f05fdd..613d21c5 100644 --- a/apps/bff/src/infra/audit/audit.module.ts +++ b/apps/bff/src/infra/audit/audit.module.ts @@ -7,5 +7,3 @@ import { AuditService } from "./audit.service"; exports: [AuditService], }) export class AuditModule {} - - diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index 554f6265..72af4518 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -191,4 +191,3 @@ export class AuditService { }; } } - diff --git a/apps/bff/src/infra/cache/cache.module.ts b/apps/bff/src/infra/cache/cache.module.ts index 77876299..586efeb5 100644 --- a/apps/bff/src/infra/cache/cache.module.ts +++ b/apps/bff/src/infra/cache/cache.module.ts @@ -7,5 +7,3 @@ import { CacheService } from "./cache.service"; exports: [CacheService], }) export class CacheModule {} - - diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index 503c6f59..ff5752e0 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -11,7 +11,17 @@ export class CacheService { async get(key: string): Promise { 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 { @@ -75,5 +85,3 @@ export class CacheService { return fresh; } } - - diff --git a/apps/bff/src/infra/database/prisma.module.ts b/apps/bff/src/infra/database/prisma.module.ts index cea3537a..1edbf95e 100644 --- a/apps/bff/src/infra/database/prisma.module.ts +++ b/apps/bff/src/infra/database/prisma.module.ts @@ -7,5 +7,3 @@ import { PrismaService } from "./prisma.service"; exports: [PrismaService], }) export class PrismaModule {} - - diff --git a/apps/bff/src/infra/database/prisma.service.ts b/apps/bff/src/infra/database/prisma.service.ts index 0740dccc..64f29f0f 100644 --- a/apps/bff/src/infra/database/prisma.service.ts +++ b/apps/bff/src/infra/database/prisma.service.ts @@ -11,5 +11,3 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul await this.$disconnect(); } } - - diff --git a/apps/bff/src/infra/email/email.module.ts b/apps/bff/src/infra/email/email.module.ts index 9fba733c..13ff096f 100644 --- a/apps/bff/src/infra/email/email.module.ts +++ b/apps/bff/src/infra/email/email.module.ts @@ -12,5 +12,3 @@ import { EmailProcessor } from "./queue/email.processor"; exports: [EmailService, EmailQueueService], }) export class EmailModule {} - - diff --git a/apps/bff/src/infra/email/email.service.ts b/apps/bff/src/infra/email/email.service.ts index e237dd70..537edbf8 100644 --- a/apps/bff/src/infra/email/email.service.ts +++ b/apps/bff/src/infra/email/email.service.ts @@ -40,5 +40,3 @@ export class EmailService { } } } - - diff --git a/apps/bff/src/infra/email/providers/sendgrid.provider.ts b/apps/bff/src/infra/email/providers/sendgrid.provider.ts index ed5e5635..9e74ca80 100644 --- a/apps/bff/src/infra/email/providers/sendgrid.provider.ts +++ b/apps/bff/src/infra/email/providers/sendgrid.provider.ts @@ -52,5 +52,3 @@ export class SendGridEmailProvider { } } } - - diff --git a/apps/bff/src/infra/email/queue/email.processor.ts b/apps/bff/src/infra/email/queue/email.processor.ts index 3b215d9c..ed9102de 100644 --- a/apps/bff/src/infra/email/queue/email.processor.ts +++ b/apps/bff/src/infra/email/queue/email.processor.ts @@ -20,5 +20,3 @@ export class EmailProcessor extends WorkerHost { this.logger.debug("Processed email job"); } } - - diff --git a/apps/bff/src/infra/email/queue/email.queue.ts b/apps/bff/src/infra/email/queue/email.queue.ts index 5524c824..b60e3924 100644 --- a/apps/bff/src/infra/email/queue/email.queue.ts +++ b/apps/bff/src/infra/email/queue/email.queue.ts @@ -28,5 +28,3 @@ export class EmailQueueService { }); } } - - diff --git a/apps/bff/src/infra/queue/queue.constants.ts b/apps/bff/src/infra/queue/queue.constants.ts index 7d71fde9..522021ca 100644 --- a/apps/bff/src/infra/queue/queue.constants.ts +++ b/apps/bff/src/infra/queue/queue.constants.ts @@ -5,5 +5,3 @@ export const QUEUE_NAMES = { } as const; export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; - - diff --git a/apps/bff/src/infra/queue/queue.module.ts b/apps/bff/src/infra/queue/queue.module.ts index 9b2b692f..7c7af431 100644 --- a/apps/bff/src/infra/queue/queue.module.ts +++ b/apps/bff/src/infra/queue/queue.module.ts @@ -39,5 +39,3 @@ function parseRedisConnection(redisUrl: string) { exports: [BullModule], }) export class QueueModule {} - - diff --git a/apps/bff/src/infra/redis/redis.module.ts b/apps/bff/src/infra/redis/redis.module.ts index 8f2b8170..a21ae36b 100644 --- a/apps/bff/src/infra/redis/redis.module.ts +++ b/apps/bff/src/infra/redis/redis.module.ts @@ -32,5 +32,3 @@ import Redis from "ioredis"; exports: ["REDIS_CLIENT"], }) export class RedisModule {} - - diff --git a/apps/bff/src/integrations/integrations.module.ts b/apps/bff/src/integrations/integrations.module.ts index 3f43bd13..15a196b9 100644 --- a/apps/bff/src/integrations/integrations.module.ts +++ b/apps/bff/src/integrations/integrations.module.ts @@ -9,5 +9,3 @@ import { FreebititModule } from "@bff/integrations/freebit/freebit.module"; exports: [WhmcsModule, SalesforceModule, FreebititModule], }) export class IntegrationsModule {} - - diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 77595155..00f4dc67 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -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 diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index afa7f0f7..e6c883d6 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -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; - 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), diff --git a/apps/bff/src/integrations/salesforce/types.d.ts b/apps/bff/src/integrations/salesforce/types.d.ts index 273d1675..a82c29ba 100644 --- a/apps/bff/src/integrations/salesforce/types.d.ts +++ b/apps/bff/src/integrations/salesforce/types.d.ts @@ -1,3 +1 @@ declare module "salesforce-pubsub-api-client"; - - diff --git a/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts b/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts new file mode 100644 index 00000000..695699b5 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/bff/src/integrations/salesforce/utils/soql.util.ts b/apps/bff/src/integrations/salesforce/utils/soql.util.ts new file mode 100644 index 00000000..f09c390d --- /dev/null +++ b/apps/bff/src/integrations/salesforce/utils/soql.util.ts @@ -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(", "); +} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index bdd4c4f5..181fb962 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -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; + } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 06b3acfa..ae64d953 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -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) { diff --git a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts index 66bb37a4..dc1c910c 100644 --- a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts +++ b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts @@ -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), - } - ); + this.logger.error(`Failed to transform invoice ${invoiceId}`, { + error: getErrorMessage(error), + whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record), + }); 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), - } - ); + this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, { + error: getErrorMessage(error), + whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record), + }); throw new Error(`Failed to transform subscription: ${getErrorMessage(error)}`); } } diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index 47d24966..b9cbfaef 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -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}`, diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index 2f26119a..c2cfc548 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -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; diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index dc2ea33f..7d9a03e6 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 31b81b84..19abd203 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -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 { 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); } - } diff --git a/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts b/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts index 3cced3c7..fb89d39d 100644 --- a/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts +++ b/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts @@ -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}`; diff --git a/apps/bff/src/modules/auth/services/token-blacklist.service.ts b/apps/bff/src/modules/auth/services/token-blacklist.service.ts index f7afb30e..aa2abdbd 100644 --- a/apps/bff/src/modules/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/services/token-blacklist.service.ts @@ -16,18 +16,15 @@ export class TokenBlacklistService { async blacklistToken(token: string, _expiresIn?: number): Promise { // 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; } } - } diff --git a/apps/bff/src/modules/auth/services/token.service.ts b/apps/bff/src/modules/auth/services/token.service.ts index fa4df055..db4ce89a 100644 --- a/apps/bff/src/modules/auth/services/token.service.ts +++ b/apps/bff/src/modules/auth/services/token.service.ts @@ -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 { + async generateTokenPair( + user: { + id: string; + email: string; + role?: string; + }, + deviceInfo?: { + deviceId?: string; + userAgent?: string; + } + ): Promise { 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 { + async refreshTokens( + refreshToken: string, + deviceInfo?: { + deviceId?: string; + userAgent?: string; + } + ): Promise { 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(); diff --git a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts index 2addf450..433dacfc 100644 --- a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts @@ -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"; diff --git a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts index 5208453e..be1dd2be 100644 --- a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts @@ -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; 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); } } diff --git a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts index 4bc3229e..f9ed93ac 100644 --- a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts @@ -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({ diff --git a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts index 99fa8791..d441967f 100644 --- a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts @@ -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 { // 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 diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index 7be8135f..681e25d0 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -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[]> { + protected async executeQuery( + soql: string, + context: string + ): Promise { try { - const res = (await this.sf.query(soql)) as { records?: Array> }; - return res.records || []; + const res = (await this.sf.query(soql)) as SalesforceQueryResult; + return res.records ?? []; } catch (error) { this.logger.error({ error, soql, context }, `Query failed: ${context}`); return []; } } - protected extractPricebookEntry( - record: Record - ): SalesforcePricebookEntryRecord | undefined { - const nested = record["PricebookEntries"] as { - records?: Array>; - } | 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( diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 4b1563a3..e0372d90 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -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( + 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( + 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( + 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)); } diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts index 74d40ca1..25b2f5f7 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts @@ -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( + 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 { const fields = this.getFields(); const soql = this.buildProductQuery("SIM", "Activation", []); - const records = await this.executeQuery(soql, "SIM Activation Fees"); + const records = await this.executeQuery( + 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( + 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)[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 }; } - } diff --git a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts index 0c0bb9ad..54487d40 100644 --- a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts @@ -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( + 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; }); } diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts index 78f9acd5..b564a5dd 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts @@ -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( + product: SalesforceCatalogProductRecord, + fieldKey: keyof typeof fieldMap.product +): T | undefined { + const salesforceField = fieldMap.product[fieldKey]; + const value = (product as Record)[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; - 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)[tierField] : undefined; - const offeringType = offeringTypeField - ? (product as Record)[offeringTypeField] - : undefined; +): Pick { + const billingCycle = getStringField(product, "billingCycle")?.toLowerCase(); + const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined; - const rawFeatures = productFields.featureList - ? (product as Record)[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 = { - 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)[dataSizeField] : undefined; - const planType = planTypeField ? (product as Record)[planTypeField] : undefined; - const familyDiscount = familyDiscountField - ? (product as Record)[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)[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, }; } - diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts index 76b061ef..e69de29b 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts @@ -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)[fields.unitPrice]); - if (productPrice !== undefined) return productPrice; - - const monthlyPrice = coerceNumber((product as Record)[fields.monthlyPrice]); - if (monthlyPrice !== undefined) return monthlyPrice; - - const oneTimePrice = coerceNumber((product as Record)[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)[fields.billingCycle]; - if (typeof billingCycle === "string" && billingCycle.toLowerCase() === "monthly") { - return unitPrice; - } - - const monthlyPrice = coerceNumber((product as Record)[fields.monthlyPrice]); - return monthlyPrice ?? undefined; -} - -export function getOneTimePrice( - product: SalesforceProduct2Record, - pricebookEntry?: SalesforcePricebookEntryRecord -): number | undefined { - const unitPrice = getUnitPrice(product, pricebookEntry); - const billingCycle = (product as Record)[fields.billingCycle]; - - if (typeof billingCycle === "string" && billingCycle.toLowerCase() !== "monthly") { - return unitPrice; - } - - const oneTimePrice = coerceNumber((product as Record)[fields.oneTimePrice]); - return oneTimePrice ?? undefined; -} - diff --git a/apps/bff/src/modules/health/health.controller.ts b/apps/bff/src/modules/health/health.controller.ts index d2dff481..1145405a 100644 --- a/apps/bff/src/modules/health/health.controller.ts +++ b/apps/bff/src/modules/health/health.controller.ts @@ -40,5 +40,3 @@ export class HealthController { return { status, checks }; } } - - diff --git a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts index efde9f5e..ce6cfde6 100644 --- a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts +++ b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts @@ -114,5 +114,3 @@ export class MappingCacheService { return `${this.CACHE_PREFIX}:${type}:${value}`; } } - - diff --git a/apps/bff/src/modules/id-mappings/mappings.module.ts b/apps/bff/src/modules/id-mappings/mappings.module.ts index 589d86e3..ecf17421 100644 --- a/apps/bff/src/modules/id-mappings/mappings.module.ts +++ b/apps/bff/src/modules/id-mappings/mappings.module.ts @@ -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 {} - - diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index 6d814668..3a470dc7 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -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; } } diff --git a/apps/bff/src/modules/id-mappings/types/mapping.types.ts b/apps/bff/src/modules/id-mappings/types/mapping.types.ts index 05dcf66c..a8780822 100644 --- a/apps/bff/src/modules/id-mappings/types/mapping.types.ts +++ b/apps/bff/src/modules/id-mappings/types/mapping.types.ts @@ -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; } - - diff --git a/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts b/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts index 2b1bc3aa..d85bb675 100644 --- a/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts +++ b/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts @@ -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; @@ -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; } -} \ No newline at end of file +} diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index d9f58c1f..fb691ba0 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -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") diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index a0ca1517..3ea70c2e 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -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) diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index 61d71bb9..386361f0 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -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, 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> { - 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 = { 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, body: OrderBusinessValidation): void { - const fields = getSalesforceFieldMap(); + private addActivationFields( + orderFields: Record, + 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, body: OrderBusinessValidation): void { - const fields = getSalesforceFieldMap(); + private addInternetFields( + orderFields: Record, + 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, 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, _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, + _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, userId: string, body: OrderBusinessValidation ): Promise { 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)?.address as | Record | 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" + ); } } } diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index d1e90aad..c8bf3969 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -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[]; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index be53eb90..7931eaa2 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -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)[fields.order.whmcsOrderId]; + const rawWhmcs = (sfOrder as Record)[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)[ - 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 { - // 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)[fieldMap.order[key]]; + return typeof raw === "string" ? raw : undefined; +} diff --git a/apps/bff/src/modules/orders/services/order-item-builder.service.ts b/apps/bff/src/modules/orders/services/order-item-builder.service.ts index abfceea0..2b25213b 100644 --- a/apps/bff/src/modules/orders/services/order-item-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-item-builder.service.ts @@ -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 { - 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)[fields.product.itemClass] : undefined; - return typeof value === "string" ? value : undefined; - })(), - internetOfferingType: (() => { - const value = product2 - ? (product2 as Record)[fields.product.internetOfferingType] - : undefined; - return typeof value === "string" ? value : undefined; - })(), - internetPlanTier: (() => { - const value = product2 - ? (product2 as Record)[fields.product.internetPlanTier] - : undefined; - return typeof value === "string" ? value : undefined; - })(), - vpnRegion: (() => { - const value = product2 ? (product2 as Record)[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), + }; } } diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 8da4eefa..14804c2d 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -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)[fieldMap.order[key]]; + return typeof raw === "string" ? raw : undefined; +} + +function pickProductString( + product: Record | 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 | 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 { - 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>, - this.sf.query(orderItemsSoql) as Promise< - SalesforceQueryResult - >, + this.sf.query(orderItemsSoql) as Promise>, ]); 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; + const ordersResult = (await this.sf.query( + ordersSoql + )) as SalesforceQueryResult; 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; 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>((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) { diff --git a/apps/bff/src/modules/orders/services/order-pricebook.service.ts b/apps/bff/src/modules/orders/services/order-pricebook.service.ts new file mode 100644 index 00000000..8c455d45 --- /dev/null +++ b/apps/bff/src/modules/orders/services/order-pricebook.service.ts @@ -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 { + 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> { + 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(); + + 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; + } +} diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index dadd1bdc..f75c0342 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -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 { 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 { - 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 }; } - } diff --git a/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts b/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts index d8f43bdd..879d8bc2 100644 --- a/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts +++ b/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts @@ -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, }; diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index 64ce2597..47fcedd1 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -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; } @@ -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 { 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 { - 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) { 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) { - // 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; - } } diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index 8153ee0d..bccbe2e3 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -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"; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index bf7df595..4665233b 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -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"; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index ececd389..fc51e218 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -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 { + 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; + } + } } diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 31a4368b..52ff879f 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -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"; diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index c4694e85..7b55dff8 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -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>; +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) { diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index 2b45e9ca..da051e35 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -31,6 +31,6 @@ "module": "CommonJS" } }, - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index aa7cedc7..20db9180 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -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, + } + : {}; interface AuthState { // State @@ -67,12 +71,8 @@ export const useAuthStore = create()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( 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()( } 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, diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 7add0bfd..84857285 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -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: , label: "Internet" }; - case "SIM": - return { icon: , label: "Mobile" }; - case "VPN": - return { icon: , label: "VPN" }; +const renderServiceIcon = (orderType?: string): ReactNode => { + const category = getServiceCategory(orderType); + switch (category) { + case "internet": + return ; + case "sim": + return ; + case "vpn": + return ; default: - return { icon: , label: "Service" }; + return ; } -} - -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 (
- {serviceType.icon} - {serviceType.label} + {serviceIcon} + {serviceLabel} Order #{order.orderNumber || String(order.id).slice(-8)}
- +
@@ -142,7 +107,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) })}

{serviceSummary}

-

{statusInfo.description}

+

{statusDescriptor.description}

{(totals.monthlyTotal > 0 || totals.oneTimeTotal > 0) && (
@@ -180,5 +145,3 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) } export default OrderCard; - - diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index de674f4c..4cd25b4a 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -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; -} +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(payload: CreateOrderRequest): Promise { - 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(payload: CreateOrderReques } async function getMyOrders(): Promise { - 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(orderId: string): Promise { - 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 } }, }); diff --git a/apps/portal/src/features/orders/utils/order-presenters.ts b/apps/portal/src/features/orders/utils/order-presenters.ts new file mode 100644 index 00000000..290b8cd5 --- /dev/null +++ b/apps/portal/src/features/orders/utils/order-presenters.ts @@ -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", + }); +} diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 93887d53..ebb7a7b7 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -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 ; - case "SIM": - return ; - case "VPN": - return ; +const renderServiceIcon = (category: ReturnType, className: string) => { + switch (category) { + case "internet": + return ; + case "sim": + return ; + case "vpn": + return ; default: - return ; + return ; } }; -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(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() {
)} - {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 ( - Status} - > -
-
{statusInfo.description}
- -
- {statusInfo.nextAction && ( -
-
- - - - Next Steps -
-

{statusInfo.nextAction}

+ {data && statusDescriptor && ( + Status}> +
+
{statusDescriptor.description}
+ +
+ {statusDescriptor.nextAction && ( +
+
+ + + + Next Steps
- )} - {statusInfo.timeline && ( -
{statusInfo.timeline}
- )} - - ); - })()} +

{statusDescriptor.nextAction}

+
+ )} + {statusDescriptor.timeline && ( +
{statusDescriptor.timeline}
+ )} +
+ )} {data && ( - {getOrderTypeIcon(data.orderType)} + {serviceIcon}

Order Items

} @@ -283,25 +212,20 @@ export function OrderDetailContainer() { ))} - {(() => { - const totals = calculateDetailedTotals(data.items || []); - return ( -
-
-
- ¥{totals.monthlyTotal.toLocaleString()} {" "} - /mo -
- {totals.oneTimeTotal > 0 && ( -
- ¥{totals.oneTimeTotal.toLocaleString()} {" "} - one-time -
- )} -
+
+
+
+ ¥{totals.monthlyTotal.toLocaleString()} {" "} + /mo
- ); - })()} + {totals.oneTimeTotal > 0 && ( +
+ ¥{totals.oneTimeTotal.toLocaleString()} {" "} + one-time +
+ )} +
+
)}
@@ -311,5 +235,3 @@ export function OrderDetailContainer() { } export default OrderDetailContainer; - - diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index 9751c6c7..593c4535 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -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: , label: "Internet Service" }; - case "SIM": - return { icon: , label: "Mobile Service" }; - case "VPN": - return { icon: , label: "VPN Service" }; - default: - return { icon: , 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 ( } @@ -208,5 +117,3 @@ export function OrdersListContainer() { } export default OrdersListContainer; - - diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 5b997d33..a7789eb0 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -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"; diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 8ace263a..55cc5461 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -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; }; - 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}`; + } +} diff --git a/packages/domain/src/contracts/salesforce.ts b/packages/domain/src/contracts/salesforce.ts index 2bd09f65..8a56d82c 100644 --- a/packages/domain/src/contracts/salesforce.ts +++ b/packages/domain/src/contracts/salesforce.ts @@ -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; } - diff --git a/packages/domain/src/entities/invoice.ts b/packages/domain/src/entities/invoice.ts index c579df1b..272cea42 100644 --- a/packages/domain/src/entities/invoice.ts +++ b/packages/domain/src/entities/invoice.ts @@ -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; diff --git a/packages/domain/src/entities/user.ts b/packages/domain/src/entities/user.ts index dfa4895e..dee971dc 100644 --- a/packages/domain/src/entities/user.ts +++ b/packages/domain/src/entities/user.ts @@ -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[]; } diff --git a/packages/domain/src/validation/api/responses.ts b/packages/domain/src/validation/api/responses.ts index 3c864b5e..96570a72 100644 --- a/packages/domain/src/validation/api/responses.ts +++ b/packages/domain/src/validation/api/responses.ts @@ -26,6 +26,3 @@ export type AuthTokensSchema = AuthResponse["tokens"]; export { orderDetailsSchema, orderSummarySchema }; export type OrderDetailsResponse = z.infer; export type OrderSummaryResponse = z.infer; -export { orderDetailsSchema, orderSummarySchema }; -export type OrderDetailsResponse = z.infer; -export type OrderSummaryResponse = z.infer; diff --git a/packages/domain/src/validation/forms/auth.ts b/packages/domain/src/validation/forms/auth.ts index 423fc520..30d3b01e 100644 --- a/packages/domain/src/validation/forms/auth.ts +++ b/packages/domain/src/validation/forms/auth.ts @@ -3,6 +3,8 @@ * Frontend form schemas that extend API request schemas with UI-specific fields */ +import { z } from "zod"; + import { loginRequestSchema, signupRequestSchema, diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index 4faa0c5e..c2b6f4f8 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -78,6 +78,8 @@ export { authResponseSchema, type AuthResponse, type AuthTokensSchema, + type OrderDetailsResponse, + type OrderSummaryResponse, } from "./api/responses"; export { diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index d909af95..43d6c3ec 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -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; export type WhmcsOrderSchema = z.infer; export type InvoiceItemSchema = z.infer; export type InvoiceSchema = z.infer; +export type InvoiceListSchema = z.infer; export type SubscriptionSchema = z.infer; export type PaymentMethodSchema = z.infer; export type PaymentSchema = z.infer;