diff --git a/CODEBASE_ANALYSIS.md b/CODEBASE_ANALYSIS.md new file mode 100644 index 00000000..10935081 --- /dev/null +++ b/CODEBASE_ANALYSIS.md @@ -0,0 +1,186 @@ +# Codebase Analysis: Remaining Errors & Redundancies + +## 🔴 **Critical Type Errors** + +### 1. **Portal API Client Method Case Issue** +**Location**: `apps/portal/src/features/checkout/services/checkout.service.ts` +```typescript +// ❌ ERROR: Property 'post' does not exist on type 'ApiClient'. Did you mean 'POST'? +return apiClient.post("/checkout/cart", { ... }); +await apiClient.post("/checkout/validate", cart); +``` + +**Fix**: Use uppercase method names +```typescript +return apiClient.POST("/checkout/cart", { ... }); +await apiClient.POST("/checkout/validate", cart); +``` + +### 2. **PricingTier Export Issue** +**Location**: `apps/portal/src/features/catalog/components/index.ts:29` +```typescript +// ❌ ERROR: Module declares 'PricingTier' locally, but it is not exported +export type { PricingDisplayProps, PricingTier } from "./base/PricingDisplay"; +``` + +**Fix**: Remove PricingTier from local export since it's now imported from domain +```typescript +export type { PricingDisplayProps } from "./base/PricingDisplay"; +``` + +### 3. **Checkout Hook Type Issue** +**Location**: `apps/portal/src/features/checkout/hooks/useCheckout.ts:115` +```typescript +// ❌ ERROR: Type 'null' is not assignable to parameter type 'OrderConfigurations | undefined' +const cart = await checkoutService.buildCart(orderType, selections, simConfig); +``` + +**Fix**: Handle null case +```typescript +const cart = await checkoutService.buildCart(orderType, selections, simConfig || undefined); +``` + +### 4. **Response Helper Type Issue** +**Location**: `apps/portal/src/lib/api/response-helpers.ts:91` +```typescript +// ❌ ERROR: Property 'data' is missing in type +``` + +**Fix**: Ensure response structure matches expected type + +--- + +## 🟠 **High Priority Redundancies** + +### 1. **SIM Action Request Types Duplication** +**Location**: `apps/portal/src/features/subscriptions/services/sim-actions.service.ts` + +**Problem**: Portal defines its own request types instead of using domain types +```typescript +// ❌ Portal defines locally +export interface TopUpRequest { + quotaMb: number; +} + +export interface ChangePlanRequest { + newPlanCode: string; + assignGlobalIp: boolean; + scheduledAt?: string; +} +``` + +**Domain already has** (`packages/domain/sim/schema.ts`): +```typescript +// ✅ Domain has validated types +export type SimTopUpRequest = z.infer; +export type SimPlanChangeRequest = z.infer; +export type SimCancelRequest = z.infer; +``` + +**Impact**: +- Portal types lack validation (no min/max for quotaMb, no format validation for scheduledAt) +- Types could diverge over time +- No runtime type checking before API calls + +### 2. **SIM Configuration Schema Missing** +**Location**: `apps/portal/src/features/catalog/hooks/useSimConfigure.ts` + +**Problem**: Broken imports from wrong domain +```typescript +// ❌ Wrong imports +import { + simConfigureFormSchema, // Does not exist + simConfigureFormToRequest, // Does not exist + type SimConfigureFormData, // Does not exist + type SimType, // Exists in domain/sim + type ActivationType, // Does not exist in billing + type MnpData, // Does not exist in billing +} from "@customer-portal/domain/billing"; // Wrong domain +``` + +**Fix**: Create proper schemas in domain/sim or domain/orders + +--- + +## 🟡 **Medium Priority Issues** + +### 3. **BFF Auth Workflow Type Issues** +**Location**: `apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts` + +**Problem**: Type '{}' is not assignable to type 'string' +```typescript +// Lines 144-147: Empty object assignments to string fields +``` + +**Fix**: Ensure proper string values or handle empty cases + +### 4. **Prisma Type Issue** +**Location**: `apps/bff/src/modules/id-mappings/mappings.service.ts:71` + +**Problem**: `Prisma.IdMapping` does not exist +```typescript +// ❌ ERROR: Namespace has no exported member 'IdMapping' +``` + +**Fix**: Check Prisma schema or use correct type name + +### 5. **Users Service Type Issue** +**Location**: `apps/bff/src/modules/users/users.service.ts:535` + +**Problem**: Type '{}' is not assignable to type 'string' +```typescript +// Line 535: Empty object assignment to string field +``` + +--- + +## 🟢 **Low Priority Cleanup Opportunities** + +### 6. **Validation Schema Organization** +**Current State**: Good separation between `common/validation.ts` and `toolkit/validation/` +**Recommendation**: Keep current structure, toolkit is for utilities, common is for validation + +### 7. **Domain Package Structure** +**Current State**: Well-organized with clear separation +**Recommendation**: No changes needed, follows good DDD principles + +### 8. **BFF Service Architecture** +**Current State**: Good separation of concerns +**Recommendation**: Consider extracting shared validation logic to domain layer + +--- + +## 📋 **Implementation Priority** + +### **Phase 1: Fix Critical Type Errors** (30 minutes) +1. Fix API client method case (`post` → `POST`) +2. Remove PricingTier from local export +3. Handle null simConfig in checkout hook +4. Fix response helper type issue + +### **Phase 2: Remove Type Duplications** (1 hour) +1. Replace Portal SIM request types with domain types +2. Create missing SIM configuration schemas +3. Update imports to use correct domain + +### **Phase 3: Fix BFF Type Issues** (30 minutes) +1. Fix auth workflow string assignments +2. Fix Prisma type reference +3. Fix users service string assignment + +### **Phase 4: Cleanup & Documentation** (30 minutes) +1. Update any remaining documentation +2. Run full type check +3. Verify all functionality works + +--- + +## 🎯 **Success Criteria** + +- ✅ All TypeScript errors resolved +- ✅ No duplicate type definitions between Portal and Domain +- ✅ All validation schemas centralized in Domain +- ✅ Portal uses Domain types exclusively +- ✅ BFF uses Domain types exclusively +- ✅ All tests passing +- ✅ Type checking passes across all workspaces diff --git a/apps/bff/src/integrations/salesforce/utils/soql.util.ts b/apps/bff/src/integrations/salesforce/utils/soql.util.ts index 961a2b89..3b120a4d 100644 --- a/apps/bff/src/integrations/salesforce/utils/soql.util.ts +++ b/apps/bff/src/integrations/salesforce/utils/soql.util.ts @@ -1,10 +1,8 @@ import { z } from "zod"; +import { nonEmptyStringSchema, salesforceIdSchema } from "@customer-portal/domain/common"; // Salesforce IDs can be 15 or 18 characters (alphanumeric) -const salesforceIdSchema = z - .string() - .regex(/^[a-zA-Z0-9]{15,18}$/, "Invalid Salesforce ID format") - .trim(); +// Note: salesforceIdSchema is now imported from domain /** * Ensures that the provided value is a Salesforce Id (15 or 18 chars alphanumeric) @@ -25,8 +23,7 @@ export function sanitizeSoqlLiteral(value: string): string { return value.replace(/\\/gu, "\\\\").replace(/'/gu, "\\'"); } -// Schema for validating non-empty string values -const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim(); +// Schema for validating SOQL field names const soqlFieldNameSchema = z .string() .trim() diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts index 03c328d3..f6d867d6 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts @@ -159,9 +159,7 @@ export class WhmcsSsoService { sso_redirect_path: modulePath, }; - const response: WhmcsSsoResponse = await this.connectionService.createSsoToken( - ssoParams - ); + const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(ssoParams); const url = this.resolveRedirectUrl(response.redirect_url); this.debugLogRedirectHost(url); diff --git a/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts index 7c3157a5..8bb499a1 100644 --- a/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts @@ -18,11 +18,6 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -interface UserMappingInfo { - userId: string; - whmcsClientId: number; -} - /** * Service responsible for retrieving invoices from WHMCS * @@ -196,7 +191,7 @@ export class InvoiceRetrievalService { /** * Get user mapping with validation */ - private async getUserMapping(userId: string): Promise { + private async getUserMapping(userId: string): Promise<{ userId: string; whmcsClientId: number }> { // Validate userId is a valid UUID validateUuidV4OrThrow(userId); diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts new file mode 100644 index 00000000..b37d7e0d --- /dev/null +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Post, Request, UsePipes, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { ZodValidationPipe } from "@bff/core/validation"; +import { CheckoutService } from "../services/checkout.service"; +import { + CheckoutCart, + checkoutCartSchema, + OrderConfigurations, +} from "@customer-portal/domain/orders"; +import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; +import { z } from "zod"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types"; + +// Request schemas for checkout endpoints +const buildCartRequestSchema = z.object({ + orderType: z.string(), + selections: z.record(z.string(), z.string()), + configuration: z.any().optional(), +}); + +const buildCartResponseSchema = apiSuccessResponseSchema(checkoutCartSchema); +const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); + +@Controller("checkout") +export class CheckoutController { + constructor( + private readonly checkoutService: CheckoutService, + @Inject(Logger) private readonly logger: Logger + ) {} + + @Post("cart") + @UsePipes(new ZodValidationPipe(buildCartRequestSchema)) + async buildCart( + @Request() req: RequestWithUser, + @Body() body: z.infer + ) { + this.logger.log("Building checkout cart", { + userId: req.user?.id, + orderType: body.orderType, + }); + + try { + const cart = await this.checkoutService.buildCart( + body.orderType, + body.selections, + body.configuration as OrderConfigurations + ); + + return buildCartResponseSchema.parse({ + success: true, + data: cart, + }); + } catch (error) { + this.logger.error("Failed to build checkout cart", { + error: error instanceof Error ? error.message : String(error), + userId: req.user?.id, + orderType: body.orderType, + }); + throw error; + } + } + + @Post("validate") + @UsePipes(new ZodValidationPipe(checkoutCartSchema)) + validateCart(@Body() cart: CheckoutCart) { + this.logger.log("Validating checkout cart", { + itemCount: cart.items.length, + }); + + try { + this.checkoutService.validateCart(cart); + + return validateCartResponseSchema.parse({ + success: true, + data: { valid: true }, + }); + } catch (error) { + this.logger.error("Checkout cart validation failed", { + error: error instanceof Error ? error.message : String(error), + itemCount: cart.items.length, + }); + throw error; + } + } +} diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index cb010523..fb86829a 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -5,12 +5,12 @@ import { Logger } from "nestjs-pino"; import { ZodValidationPipe } from "@bff/core/validation"; import { createOrderRequestSchema, + orderCreateResponseSchema, sfOrderIdParamSchema, type CreateOrderRequest, type SfOrderIdParam, } from "@customer-portal/domain/orders"; import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; -import { z } from "zod"; @Controller("orders") export class OrdersController { @@ -19,13 +19,7 @@ export class OrdersController { private readonly logger: Logger ) {} - private readonly createOrderResponseSchema = apiSuccessResponseSchema( - z.object({ - sfOrderId: z.string(), - status: z.string(), - message: z.string(), - }) - ); + private readonly createOrderResponseSchema = apiSuccessResponseSchema(orderCreateResponseSchema); @Post() @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 0d84f3d3..e73d23b4 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -1,10 +1,12 @@ import { Module } from "@nestjs/common"; import { OrdersController } from "./orders.controller"; +import { CheckoutController } from "./controllers/checkout.controller"; import { IntegrationsModule } from "@bff/integrations/integrations.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { UsersModule } from "@bff/modules/users/users.module"; import { CoreConfigModule } from "@bff/core/config/config.module"; import { DatabaseModule } from "@bff/core/database/database.module"; +import { CatalogModule } from "@bff/modules/catalog/catalog.module"; // Clean modular order services import { OrderValidator } from "./services/order-validator.service"; @@ -13,6 +15,7 @@ import { OrderItemBuilder } from "./services/order-item-builder.service"; import { OrderPricebookService } from "./services/order-pricebook.service"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; import { PaymentValidatorService } from "./services/payment-validator.service"; +import { CheckoutService } from "./services/checkout.service"; // Clean modular fulfillment services import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service"; @@ -23,8 +26,15 @@ import { ProvisioningQueueService } from "./queue/provisioning.queue"; import { ProvisioningProcessor } from "./queue/provisioning.processor"; @Module({ - imports: [IntegrationsModule, MappingsModule, UsersModule, CoreConfigModule, DatabaseModule], - controllers: [OrdersController], + imports: [ + IntegrationsModule, + MappingsModule, + UsersModule, + CoreConfigModule, + DatabaseModule, + CatalogModule, + ], + controllers: [OrdersController, CheckoutController], providers: [ // Shared services PaymentValidatorService, @@ -35,6 +45,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor"; OrderItemBuilder, OrderPricebookService, OrderOrchestrator, + CheckoutService, // Order fulfillment services (modular) OrderFulfillmentValidator, @@ -45,6 +56,6 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor"; ProvisioningQueueService, ProvisioningProcessor, ], - exports: [OrderOrchestrator, ProvisioningQueueService], + exports: [OrderOrchestrator, CheckoutService, ProvisioningQueueService], }) export class OrdersModule {} diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts new file mode 100644 index 00000000..c5e71cf6 --- /dev/null +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -0,0 +1,368 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { + CheckoutCart, + CheckoutItem, + CheckoutTotals, + checkoutCartSchema, + OrderConfigurations, + ORDER_TYPE, +} from "@customer-portal/domain/orders"; +import type { + InternetPlanCatalogItem, + InternetAddonCatalogItem, + InternetInstallationCatalogItem, + SimCatalogProduct, + SimActivationFeeCatalogItem, + VpnCatalogProduct, +} from "@customer-portal/domain/catalog"; +import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service"; +import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service"; +import { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service"; +import { getErrorMessage } from "@bff/core/utils/error.util"; + +@Injectable() +export class CheckoutService { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly internetCatalogService: InternetCatalogService, + private readonly simCatalogService: SimCatalogService, + private readonly vpnCatalogService: VpnCatalogService + ) {} + + /** + * Build checkout cart from order type and selections + */ + async buildCart( + orderType: string, + selections: Record, + configuration?: OrderConfigurations + ): Promise { + this.logger.log("Building checkout cart", { orderType, selections }); + + try { + const items: CheckoutItem[] = []; + let totals: CheckoutTotals = { monthlyTotal: 0, oneTimeTotal: 0 }; + + if (orderType === ORDER_TYPE.INTERNET) { + const cart = await this.buildInternetCart(selections); + items.push(...cart.items); + totals = this.calculateTotals(items); + } else if (orderType === ORDER_TYPE.SIM) { + const cart = await this.buildSimCart(selections); + items.push(...cart.items); + totals = this.calculateTotals(items); + } else if (orderType === ORDER_TYPE.VPN) { + const cart = await this.buildVpnCart(selections); + items.push(...cart.items); + totals = this.calculateTotals(items); + } else { + throw new BadRequestException(`Unsupported order type: ${orderType}`); + } + + const cart: CheckoutCart = { + items, + totals, + configuration: configuration || ({} as OrderConfigurations), + }; + + // Validate the cart using domain schema + const validatedCart = checkoutCartSchema.parse(cart); + + this.logger.log("Checkout cart built successfully", { + itemCount: validatedCart.items.length, + monthlyTotal: validatedCart.totals.monthlyTotal, + oneTimeTotal: validatedCart.totals.oneTimeTotal, + }); + + return validatedCart; + } catch (error) { + this.logger.error("Failed to build checkout cart", { + error: getErrorMessage(error), + orderType, + selections, + }); + throw error; + } + } + + /** + * Calculate totals for checkout items + */ + calculateTotals(items: CheckoutItem[]): CheckoutTotals { + return items.reduce( + (acc, item) => { + if (typeof item.monthlyPrice === "number") { + acc.monthlyTotal += item.monthlyPrice * item.quantity; + } + if (typeof item.oneTimePrice === "number") { + acc.oneTimeTotal += item.oneTimePrice * item.quantity; + } + return acc; + }, + { monthlyTotal: 0, oneTimeTotal: 0 } + ); + } + + /** + * Validate checkout cart for business rules + */ + validateCart(cart: CheckoutCart): void { + this.logger.log("Validating checkout cart", { itemCount: cart.items.length }); + + // Validate cart structure using domain schema + checkoutCartSchema.parse(cart); + + // Business validation rules + if (cart.items.length === 0) { + throw new BadRequestException("Cart cannot be empty"); + } + + // Validate SKUs exist in catalog + for (const item of cart.items) { + this.validateSkuExists(item.sku); + } + + // Validate quantities + for (const item of cart.items) { + if (item.quantity <= 0) { + throw new BadRequestException(`Invalid quantity for ${item.sku}: ${item.quantity}`); + } + } + + this.logger.log("Checkout cart validation passed"); + } + + /** + * Build Internet order cart + */ + private async buildInternetCart( + selections: Record + ): Promise<{ items: CheckoutItem[] }> { + const items: CheckoutItem[] = []; + const plans: InternetPlanCatalogItem[] = await this.internetCatalogService.getPlans(); + const addons: InternetAddonCatalogItem[] = await this.internetCatalogService.getAddons(); + const installations: InternetInstallationCatalogItem[] = + await this.internetCatalogService.getInstallations(); + + // Add main plan + const planRef = + selections.plan || selections.planId || selections.planSku || selections.planIdSku; + if (!planRef) { + throw new BadRequestException("No plan selected for Internet order"); + } + + const plan = plans.find(p => p.sku === planRef || p.id === planRef); + if (!plan) { + throw new BadRequestException(`Internet plan not found: ${planRef}`); + } + + items.push({ + id: plan.id, + sku: plan.sku, + name: plan.name, + description: plan.description, + monthlyPrice: plan.monthlyPrice, + oneTimePrice: plan.oneTimePrice, + quantity: 1, + itemType: "plan", + }); + + // Add installation if selected + if (selections.installationSku) { + const installation = installations.find(inst => inst.sku === selections.installationSku); + if (installation) { + items.push({ + id: installation.id, + sku: installation.sku, + name: installation.name, + description: installation.description, + monthlyPrice: installation.monthlyPrice, + oneTimePrice: installation.oneTimePrice, + quantity: 1, + itemType: "installation", + }); + } + } + + // Add addons + const addonRefs = this.collectAddonRefs(selections); + for (const ref of addonRefs) { + const addon = addons.find(a => a.sku === ref || a.id === ref); + if (addon) { + items.push({ + id: addon.id, + sku: addon.sku, + name: addon.name, + description: addon.description, + monthlyPrice: addon.monthlyPrice, + oneTimePrice: addon.oneTimePrice, + quantity: 1, + itemType: "addon", + }); + } + } + + return { items }; + } + + /** + * Build SIM order cart + */ + private async buildSimCart( + selections: Record + ): Promise<{ items: CheckoutItem[] }> { + const items: CheckoutItem[] = []; + const plans: SimCatalogProduct[] = await this.simCatalogService.getPlans(); + const activationFees: SimActivationFeeCatalogItem[] = + await this.simCatalogService.getActivationFees(); + const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons(); + + // Add main plan + const planRef = + selections.plan || selections.planId || selections.planSku || selections.planIdSku; + if (!planRef) { + throw new BadRequestException("No plan selected for SIM order"); + } + + const plan = plans.find(p => p.sku === planRef || p.id === planRef); + if (!plan) { + throw new BadRequestException(`SIM plan not found: ${planRef}`); + } + + items.push({ + id: plan.id, + sku: plan.sku, + name: plan.name, + description: plan.description, + monthlyPrice: plan.monthlyPrice, + oneTimePrice: plan.oneTimePrice, + quantity: 1, + itemType: "plan", + }); + + // Add activation fee + const simType = selections.simType || "eSIM"; + const activation = activationFees.find(fee => { + const metadata = fee.catalogMetadata as { simType?: string } | undefined; + const feeSimType = metadata?.simType; + return feeSimType ? feeSimType === simType : fee.sku === selections.activationFeeSku; + }); + + if (activation) { + items.push({ + id: activation.id, + sku: activation.sku, + name: activation.name, + description: activation.description, + monthlyPrice: activation.monthlyPrice, + oneTimePrice: activation.oneTimePrice, + quantity: 1, + itemType: "activation", + }); + } + + // Add addons + const addonRefs = this.collectAddonRefs(selections); + for (const ref of addonRefs) { + const addon = addons.find(a => a.sku === ref || a.id === ref); + if (addon) { + items.push({ + id: addon.id, + sku: addon.sku, + name: addon.name, + description: addon.description, + monthlyPrice: addon.monthlyPrice, + oneTimePrice: addon.oneTimePrice, + quantity: 1, + itemType: "addon", + }); + } + } + + return { items }; + } + + /** + * Build VPN order cart + */ + private async buildVpnCart( + selections: Record + ): Promise<{ items: CheckoutItem[] }> { + const items: CheckoutItem[] = []; + const plans: VpnCatalogProduct[] = await this.vpnCatalogService.getPlans(); + const activationFees: VpnCatalogProduct[] = await this.vpnCatalogService.getActivationFees(); + + // Add main plan + const planRef = + selections.plan || selections.planId || selections.planSku || selections.planIdSku; + if (!planRef) { + throw new BadRequestException("No plan selected for VPN order"); + } + + const plan = plans.find(p => p.sku === planRef || p.id === planRef); + if (!plan) { + throw new BadRequestException(`VPN plan not found: ${planRef}`); + } + + items.push({ + id: plan.id, + sku: plan.sku, + name: plan.name, + description: plan.description, + monthlyPrice: plan.monthlyPrice, + oneTimePrice: plan.oneTimePrice, + quantity: 1, + itemType: "vpn", + }); + + // Add activation fee if selected + if (selections.activationSku) { + const activation = activationFees.find(fee => fee.sku === selections.activationSku); + if (activation) { + items.push({ + id: activation.id, + sku: activation.sku, + name: activation.name, + description: activation.description, + monthlyPrice: activation.monthlyPrice, + oneTimePrice: activation.oneTimePrice, + quantity: 1, + itemType: "activation", + }); + } + } + + return { items }; + } + + /** + * Collect addon references from selections + */ + private collectAddonRefs(selections: Record): string[] { + const refs = new Set(); + + // Handle various addon selection formats + if (selections.addonSku) refs.add(selections.addonSku); + if (selections.addons) { + selections.addons + .split(",") + .map(value => value.trim()) + .filter(Boolean) + .forEach(value => refs.add(value)); + } + + return Array.from(refs); + } + + /** + * Validate that SKU exists in catalog + */ + private validateSkuExists(sku: string): void { + // This would typically check against the catalog service + // For now, we'll do a basic validation + if (!sku || sku.trim().length === 0) { + throw new BadRequestException("Invalid SKU: empty or null"); + } + } +} 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 4d0cda02..a3f48138 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 @@ -6,15 +6,16 @@ import { WhmcsOrderResult, } from "@bff/integrations/whmcs/services/whmcs-order.service"; import { OrderOrchestrator } from "./order-orchestrator.service"; -import { - OrderFulfillmentValidator, - OrderFulfillmentValidationResult, -} from "./order-fulfillment-validator.service"; +import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"; import { SimFulfillmentService } from "./sim-fulfillment.service"; import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { type OrderDetails, Providers as OrderProviders } from "@customer-portal/domain/orders"; +import { + type OrderDetails, + type OrderFulfillmentValidationResult, + Providers as OrderProviders, +} from "@customer-portal/domain/orders"; type WhmcsOrderItemMappingResult = ReturnType; 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 729b49e3..16d62c15 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 @@ -1,23 +1,16 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { z } from "zod"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { SalesforceOrderRecord } from "@customer-portal/domain/orders"; -import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; +import { + OrderFulfillmentValidationResult, + sfOrderIdParamSchema, +} from "@customer-portal/domain/orders"; +import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; +import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers/salesforce/raw.types"; import { PaymentValidatorService } from "./payment-validator.service"; -// Schema for validating Salesforce Account ID -const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required"); - -export interface OrderFulfillmentValidationResult { - sfOrder: SalesforceOrderRecord; - clientId: number; - isAlreadyProvisioned: boolean; - whmcsOrderId?: string; -} - /** * Handles all order fulfillment validation logic * Similar to OrderValidator but for fulfillment workflow diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index fff0071a..43460ac8 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { format } from "date-fns"; import { @@ -60,45 +60,54 @@ export function InvoiceTable({ const [downloadLoading, setDownloadLoading] = useState(null); const createSsoLinkMutation = useCreateInvoiceSsoLink(); - const handleInvoiceClick = (invoice: Invoice) => { - if (onInvoiceClick) { - onInvoiceClick(invoice); - } else { - router.push(`/billing/invoices/${invoice.id}`); - } - }; + const handleInvoiceClick = useCallback( + (invoice: Invoice) => { + if (onInvoiceClick) { + onInvoiceClick(invoice); + } else { + router.push(`/billing/invoices/${invoice.id}`); + } + }, + [onInvoiceClick, router] + ); - const handlePayment = async (invoice: Invoice, event: React.MouseEvent) => { - event.stopPropagation(); // Prevent row click - setPaymentLoading(invoice.id); - try { - const ssoLink = await createSsoLinkMutation.mutateAsync({ - invoiceId: invoice.id, - target: "pay", - }); - openSsoLink(ssoLink.url, { newTab: true }); - } catch (err) { - logger.error(err, "Failed to create payment SSO link"); - } finally { - setPaymentLoading(null); - } - }; + const handlePayment = useCallback( + async (invoice: Invoice, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent row click + setPaymentLoading(invoice.id); + try { + const ssoLink = await createSsoLinkMutation.mutateAsync({ + invoiceId: invoice.id, + target: "pay", + }); + openSsoLink(ssoLink.url, { newTab: true }); + } catch (err) { + logger.error(err, "Failed to create payment SSO link"); + } finally { + setPaymentLoading(null); + } + }, + [createSsoLinkMutation] + ); - const handleDownload = async (invoice: Invoice, event: React.MouseEvent) => { - event.stopPropagation(); // Prevent row click - setDownloadLoading(invoice.id); - try { - const ssoLink = await createSsoLinkMutation.mutateAsync({ - invoiceId: invoice.id, - target: "download", - }); - openSsoLink(ssoLink.url, { newTab: false }); - } catch (err) { - logger.error(err, "Failed to create download SSO link"); - } finally { - setDownloadLoading(null); - } - }; + const handleDownload = useCallback( + async (invoice: Invoice, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent row click + setDownloadLoading(invoice.id); + try { + const ssoLink = await createSsoLinkMutation.mutateAsync({ + invoiceId: invoice.id, + target: "download", + }); + openSsoLink(ssoLink.url, { newTab: false }); + } catch (err) { + logger.error(err, "Failed to create download SSO link"); + } finally { + setDownloadLoading(null); + } + }, + [createSsoLinkMutation] + ); const columns = useMemo(() => { const baseColumns = [ @@ -251,7 +260,7 @@ export function InvoiceTable({ } return baseColumns; - }, [compact, showActions, paymentLoading, downloadLoading]); + }, [compact, showActions, paymentLoading, downloadLoading, handlePayment, handleDownload]); const emptyState = { icon: , diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx index 3ea00b7a..4821cc8c 100644 --- a/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx +++ b/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx @@ -14,9 +14,6 @@ import { cn } from "@/lib/utils"; interface PaymentMethodCardProps extends React.HTMLAttributes { paymentMethod: PaymentMethod; - onEdit?: (paymentMethod: PaymentMethod) => void; - onDelete?: (paymentMethod: PaymentMethod) => void; - onSetDefault?: (paymentMethod: PaymentMethod) => void; showActions?: boolean; compact?: boolean; } @@ -53,19 +50,7 @@ const getCardBrandColor = (cardBrand?: string) => { }; const PaymentMethodCard = forwardRef( - ( - { - paymentMethod, - onEdit, - onDelete, - onSetDefault, - showActions = true, - compact = false, - className, - ...props - }, - ref - ) => { + ({ paymentMethod, showActions = true, compact = false, className, ...props }, ref) => { const { type, description, diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts index 6b9bd52f..613a61d1 100644 --- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts +++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts @@ -31,7 +31,7 @@ export function usePaymentRefresh({ try { try { await apiClient.POST("/api/invoices/payment-methods/refresh"); - } catch (err) { + } catch { // Soft-fail cache refresh, still attempt refetch // Payment methods cache refresh failed - silently continue } diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index fb860213..af205a20 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -8,10 +8,8 @@ import { ErrorState } from "@/components/atoms/error-state"; import { CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; import { PageLayout } from "@/components/templates/PageLayout"; import { logger } from "@customer-portal/logging"; -import { apiClient, getDataOrThrow } from "@/lib/api"; import { openSsoLink } from "@/features/billing/utils/sso"; import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks"; -import type { InvoiceSsoLink } from "@customer-portal/domain/billing"; import { InvoiceItems, InvoiceTotals, @@ -22,11 +20,9 @@ export function InvoiceDetailContainer() { const params = useParams(); const [loadingDownload, setLoadingDownload] = useState(false); const [loadingPayment, setLoadingPayment] = useState(false); - const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false); const rawInvoiceParam = params.id; const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam; - const invoiceId = Number.parseInt(invoiceIdParam ?? "", 10); const createSsoLinkMutation = useCreateInvoiceSsoLink(); const { data: invoice, isLoading, error } = useInvoice(invoiceIdParam ?? ""); @@ -48,26 +44,6 @@ export function InvoiceDetailContainer() { })(); }; - const handleManagePaymentMethods = () => { - void (async () => { - setLoadingPaymentMethods(true); - try { - const response = await apiClient.POST("/auth/sso-link", { - body: { path: "index.php?rp=/account/paymentmethods" }, - }); - const sso = getDataOrThrow( - response, - "Failed to create payment methods SSO link" - ); - openSsoLink(sso.url, { newTab: true }); - } catch (err) { - logger.error(err, "Failed to create payment methods SSO link"); - } finally { - setLoadingPaymentMethods(false); - } - })(); - }; - if (isLoading) { return ( (null); diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index 65825d85..67767203 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -9,13 +9,7 @@ import { useState, useEffect, useCallback } from "react"; import { accountService } from "@/features/account/services/account.service"; import { log } from "@customer-portal/logging"; import { StatusPill } from "@/components/atoms/status-pill"; -import { - MapPinIcon, - PencilIcon, - CheckIcon, - XMarkIcon, - ExclamationTriangleIcon, -} from "@heroicons/react/24/outline"; +import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries"; // Use canonical Address type from domain diff --git a/apps/portal/src/features/catalog/components/base/AddressForm.tsx b/apps/portal/src/features/catalog/components/base/AddressForm.tsx index f1e0cdf8..9ae64131 100644 --- a/apps/portal/src/features/catalog/components/base/AddressForm.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressForm.tsx @@ -35,10 +35,15 @@ export interface AddressFormProps { disabled?: boolean; // Validation - validateOnChange?: boolean; customValidation?: (address: Partial
) => string[]; } +const normalizeCountryValue = (value?: string | null) => { + if (!value) return ""; + if (value.length === 2) return value.toUpperCase(); + return getCountryCodeByName(value) ?? value; +}; + const DEFAULT_LABELS: Partial> = { address1: "Address Line 1", address2: "Address Line 2", @@ -85,19 +90,12 @@ export function AddressForm({ fieldPlaceholders = {}, variant = "default", disabled = false, - validateOnChange = true, customValidation, }: AddressFormProps) { const labels = { ...DEFAULT_LABELS, ...fieldLabels }; const placeholders = { ...DEFAULT_PLACEHOLDERS, ...fieldPlaceholders }; // Create initial values with proper defaults - const normalizeCountryValue = (value?: string | null) => { - if (!value) return ""; - if (value.length === 2) return value.toUpperCase(); - return getCountryCodeByName(value) ?? value; - }; - const initialCountry = normalizeCountryValue(initialAddress.country); const initialCountryCode = normalizeCountryValue(initialAddress.countryCode ?? initialCountry); @@ -179,7 +177,7 @@ export function AddressForm({ form.setValue("country", normalizedCountry); form.setValue("countryCode", normalizedCountryCode); } - }, [initialAddress]); + }, [form, initialAddress]); // Notify parent of validation changes useEffect(() => { diff --git a/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx b/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx index 7548c266..fc8928f2 100644 --- a/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx +++ b/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx @@ -1,7 +1,6 @@ "use client"; import { ReactNode } from "react"; -import Link from "next/link"; import { CheckCircleIcon, ExclamationTriangleIcon, diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index a6fb09b5..e1a57762 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -1,12 +1,7 @@ "use client"; import { ReactNode } from "react"; -import { - ArrowLeftIcon, - ArrowRightIcon, - CurrencyYenIcon, - InformationCircleIcon, -} from "@heroicons/react/24/outline"; +import { ArrowLeftIcon, ArrowRightIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; import { AnimatedCard } from "@/components/molecules"; import { Formatting } from "@customer-portal/domain/toolkit"; @@ -130,10 +125,6 @@ export function EnhancedOrderSummary({ const monthlyItems = items.filter(item => item.billingCycle === "Monthly"); const oneTimeItems = items.filter(item => item.billingCycle === "Onetime"); - const serviceItems = items.filter(item => item.itemClass === "Service"); - const addonItems = items.filter(item => item.itemClass === "Add-on"); - const installationItems = items.filter(item => item.itemClass === "Installation"); - const activationItems = items.filter(item => item.itemClass === "Activation"); return ( diff --git a/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx b/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx index d8d0e70d..79ab096c 100644 --- a/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx +++ b/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx @@ -3,19 +3,10 @@ import { ReactNode } from "react"; import { CurrencyYenIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; import { Formatting } from "@customer-portal/domain/toolkit"; +import type { PricingTier } from "@customer-portal/domain/catalog"; const { formatCurrency } = Formatting; -export interface PricingTier { - name: string; - price: number; - billingCycle: "Monthly" | "Onetime" | "Annual"; - description?: string; - features?: string[]; - isRecommended?: boolean; - originalPrice?: number; // For showing discounts -} - export interface PricingDisplayProps { // Single price mode monthlyPrice?: number; diff --git a/apps/portal/src/features/catalog/components/base/ProductCard.tsx b/apps/portal/src/features/catalog/components/base/ProductCard.tsx index 2966aafc..feb73ea5 100644 --- a/apps/portal/src/features/catalog/components/base/ProductCard.tsx +++ b/apps/portal/src/features/catalog/components/base/ProductCard.tsx @@ -43,9 +43,7 @@ export interface ProductCardProps { } export function ProductCard({ - id, name, - sku, description, monthlyPrice, oneTimePrice, diff --git a/apps/portal/src/features/catalog/components/index.ts b/apps/portal/src/features/catalog/components/index.ts index 56718b8b..b7fa0154 100644 --- a/apps/portal/src/features/catalog/components/index.ts +++ b/apps/portal/src/features/catalog/components/index.ts @@ -26,7 +26,7 @@ export { AddressConfirmation } from "./base/AddressConfirmation"; // Re-export types export type { ProductCardProps } from "./base/ProductCard"; -export type { PricingDisplayProps, PricingTier } from "./base/PricingDisplay"; +export type { PricingDisplayProps } from "./base/PricingDisplay"; export type { ProductComparisonProps, ComparisonProduct, diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx index 6a17b40d..3c5c7bc9 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx @@ -42,7 +42,6 @@ export function InternetConfigureContainer({ isTransitioning, mode, selectedInstallation, - selectedInstallationType, selectedAddonSkus, monthlyTotal, oneTimeTotal, diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx index 82beef4f..4ffba141 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx @@ -2,7 +2,6 @@ import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; -import { StepHeader } from "@/components/atoms"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog"; diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx index 54642d39..a576373c 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx @@ -5,11 +5,6 @@ import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; -interface SimPlanCardProps { - plan: SimCatalogProduct; - onSelect: (plan: SimCatalogProduct) => void; -} - export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) { const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx index c32bb8ff..f56ad849 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx @@ -5,12 +5,6 @@ import { UsersIcon } from "@heroicons/react/24/outline"; import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; import { SimPlanCard } from "./SimPlanCard"; -interface SimPlanTypeSectionProps { - plans: SimCatalogProduct[]; - selectedPlanId: string | null; - onPlanSelect: (plan: SimCatalogProduct) => void; -} - export function SimPlanTypeSection({ title, description, diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 8e315c33..18570d8e 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -1,6 +1,5 @@ import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api"; import { - EMPTY_INTERNET_CATALOG, EMPTY_SIM_CATALOG, EMPTY_VPN_CATALOG, internetInstallationCatalogItemSchema, @@ -20,7 +19,6 @@ import { type VpnCatalogCollection, type VpnCatalogProduct, } from "@customer-portal/domain/catalog"; -import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog"; export const catalogService = { async getInternetCatalog(): Promise { diff --git a/apps/portal/src/features/catalog/utils/catalog.utils.ts b/apps/portal/src/features/catalog/utils/catalog.utils.ts index 9fe76d1a..8ea3d7c0 100644 --- a/apps/portal/src/features/catalog/utils/catalog.utils.ts +++ b/apps/portal/src/features/catalog/utils/catalog.utils.ts @@ -3,7 +3,6 @@ * Helper functions for catalog operations */ -import { Formatting } from "@customer-portal/domain/toolkit"; import type { InternetPlanCatalogItem, InternetAddonCatalogItem, @@ -12,16 +11,6 @@ import type { VpnCatalogProduct, } from "@customer-portal/domain/catalog"; -const { formatCurrency } = Formatting; - -// TODO: Define CatalogFilter type properly -type CatalogFilter = { - category?: string; - priceMin?: number; - priceMax?: number; - search?: string; -}; - type CatalogProduct = | InternetPlanCatalogItem | InternetAddonCatalogItem @@ -35,11 +24,56 @@ type CatalogProduct = */ export function filterProducts( products: CatalogProduct[], - filters: CatalogFilter + filters: { + category?: string; + priceMin?: number; + priceMax?: number; + search?: string; + } ): CatalogProduct[] { return products.filter(product => { - // Basic filtering by product name/description if needed - // Most filtering should be done server-side via the catalog service + if (filters.category) { + const normalizedCategory = filters.category.toLowerCase(); + const hasItemClass = + "itemClass" in product && typeof product.itemClass === "string"; + const hasInternetTier = "internetPlanTier" in product; + const hasSimType = "simPlanType" in product; + const hasVpnRegion = "vpnRegion" in product; + + const categoryMatches = + (hasItemClass && product.itemClass.toLowerCase() === normalizedCategory) || + (hasInternetTier && normalizedCategory === "internet") || + (hasSimType && normalizedCategory === "sim") || + (hasVpnRegion && normalizedCategory === "vpn"); + if (!categoryMatches) { + return false; + } + } + + if (typeof filters.priceMin === "number") { + const price = (product as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ?? + (product as { oneTimePrice?: number }).oneTimePrice ?? 0; + if (price < filters.priceMin) { + return false; + } + } + + if (typeof filters.priceMax === "number") { + const price = (product as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ?? + (product as { oneTimePrice?: number }).oneTimePrice ?? 0; + if (price > filters.priceMax) { + return false; + } + } + + const search = filters.search?.toLowerCase(); + if (search) { + const nameMatch = product.name.toLowerCase().includes(search); + const descriptionMatch = product.description?.toLowerCase().includes(search) ?? false; + if (!nameMatch && !descriptionMatch) { + return false; + } + } return true; }); } @@ -50,9 +84,18 @@ export function filterProducts( */ export function sortProducts( products: CatalogProduct[], - sortBy: "name" = "name" + sortBy: "name" | "price" = "name" ): CatalogProduct[] { const sorted = [...products]; + if (sortBy === "price") { + return sorted.sort((a, b) => { + const aPrice = (a as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ?? + (a as { oneTimePrice?: number }).oneTimePrice ?? 0; + const bPrice = (b as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ?? + (b as { oneTimePrice?: number }).oneTimePrice ?? 0; + return aPrice - bPrice; + }); + } return sorted.sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index a23f0ece..7d0d1c47 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -1,13 +1,11 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { PageLayout } from "@/components/templates/PageLayout"; import { WifiIcon, ServerIcon, - CurrencyYenIcon, ArrowLeftIcon, - ArrowRightIcon, HomeIcon, BuildingOfficeIcon, } from "@heroicons/react/24/outline"; @@ -16,10 +14,8 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, - InternetAddonCatalogItem, } from "@customer-portal/domain/catalog"; -import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton"; -import { AnimatedCard } from "@/components/molecules"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { Button } from "@/components/atoms/button"; import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard"; @@ -27,9 +23,14 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; export function InternetPlansContainer() { const { data, isLoading, error } = useInternetCatalog(); - const plans: InternetPlanCatalogItem[] = data?.plans || []; - const installations: InternetInstallationCatalogItem[] = data?.installations || []; - const addons: InternetAddonCatalogItem[] = data?.addons || []; + const plans: InternetPlanCatalogItem[] = useMemo( + () => data?.plans ?? [], + [data?.plans] + ); + const installations: InternetInstallationCatalogItem[] = useMemo( + () => data?.installations ?? [], + [data?.installations] + ); const [eligibility, setEligibility] = useState(""); const { data: activeSubs } = useActiveSubscriptions(); const hasActiveInternet = Array.isArray(activeSubs) diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index cd3b11fd..11685658 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { PageLayout } from "@/components/templates/PageLayout"; import { DevicePhoneMobileIcon, @@ -8,10 +8,8 @@ import { PhoneIcon, GlobeAltIcon, ArrowLeftIcon, - InformationCircleIcon, - UsersIcon, } from "@heroicons/react/24/outline"; -import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Button } from "@/components/atoms/button"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { useSimCatalog } from "@/features/catalog/hooks"; @@ -26,7 +24,7 @@ interface PlansByType { export function SimPlansContainer() { const { data, isLoading, error } = useSimCatalog(); - const simPlans: SimCatalogProduct[] = data?.plans ?? []; + const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); const [hasExistingSim, setHasExistingSim] = useState(false); const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( "data-voice" diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 99bc3c53..60bd969a 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -2,11 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { catalogService } from "@/features/catalog/services/catalog.service"; import { ordersService } from "@/features/orders/services/orders.service"; +import { checkoutService } from "@/features/checkout/services/checkout.service"; import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import { createLoadingState, createSuccessState, @@ -17,32 +16,13 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr import { ORDER_TYPE, orderConfigurationsSchema, - type OrderConfigurations, type OrderTypeValue, + type CheckoutCart, } from "@customer-portal/domain/orders"; // Use domain Address type import type { Address } from "@customer-portal/domain/customer"; -type CheckoutItemType = "plan" | "installation" | "addon" | "activation" | "vpn"; - -interface CheckoutItem extends CatalogProductBase { - quantity: number; - itemType: CheckoutItemType; - autoAdded?: boolean; -} - -interface CheckoutTotals { - monthlyTotal: number; - oneTimeTotal: number; -} - -interface CheckoutCart { - items: CheckoutItem[]; - totals: CheckoutTotals; - configuration: OrderConfigurations; -} - export function useCheckout() { const params = useSearchParams(); const router = useRouter(); @@ -115,36 +95,6 @@ export function useCheckout() { useEffect(() => { let mounted = true; - const collectAddonRefs = () => { - const refs = new Set(); - params.getAll("addonSku").forEach(sku => { - if (sku) refs.add(sku); - }); - if (selections.addonSku) refs.add(selections.addonSku); - if (selections.addons) { - selections.addons - .split(",") - .map(value => value.trim()) - .filter(Boolean) - .forEach(value => refs.add(value)); - } - return Array.from(refs); - }; - - const calculateTotals = (items: CheckoutItem[]): CheckoutTotals => - items.reduce( - (acc, item) => { - if (typeof item.monthlyPrice === "number") { - acc.monthlyTotal += item.monthlyPrice * item.quantity; - } - if (typeof item.oneTimePrice === "number") { - acc.oneTimeTotal += item.oneTimePrice * item.quantity; - } - return acc; - }, - { monthlyTotal: 0, oneTimeTotal: 0 } - ); - void (async () => { try { setCheckoutState(createLoadingState()); @@ -160,109 +110,12 @@ export function useCheckout() { throw new Error("No plan selected. Please go back and select a plan."); } - const addonRefs = collectAddonRefs(); - const items: CheckoutItem[] = []; - - if (orderType === "Internet") { - const { plans, addons, installations } = await catalogService.getInternetCatalog(); - - const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null; - if (!plan) { - throw new Error( - `Internet plan not found for reference: ${planRef}. Please go back and select a valid plan.` - ); - } - - items.push({ ...plan, quantity: 1, itemType: "plan" }); - - if (selections.installationSku) { - const installation = - installations.find( - inst => - inst.sku === selections.installationSku || inst.id === selections.installationSku - ) ?? null; - if (!installation) { - throw new Error( - `Installation option not found for reference: ${selections.installationSku}. Please reselect your installation method.` - ); - } - items.push({ ...installation, quantity: 1, itemType: "installation" }); - } - - addonRefs.forEach(ref => { - const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null; - if (addon) { - items.push({ ...addon, quantity: 1, itemType: "addon" }); - } - }); - } else if (orderType === "SIM") { - const { plans, activationFees, addons } = await catalogService.getSimCatalog(); - - const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null; - if (!plan) { - throw new Error( - `SIM plan not found for reference: ${planRef}. Please go back and select a valid plan.` - ); - } - - items.push({ ...plan, quantity: 1, itemType: "plan" }); - - addonRefs.forEach(ref => { - const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null; - if (addon) { - items.push({ ...addon, quantity: 1, itemType: "addon" }); - } - }); - - const simType = selections.simType ?? "eSIM"; - const activation = - activationFees.find(fee => { - const feeSimType = - (fee as unknown as { simType?: string }).simType || - ((fee.catalogMetadata as { simType?: string } | undefined)?.simType ?? undefined); - return feeSimType - ? feeSimType === simType - : fee.sku === selections.activationFeeSku || fee.id === selections.activationFeeSku; - }) ?? null; - - if (activation) { - items.push({ ...activation, quantity: 1, itemType: "activation" }); - } - } else if (orderType === "VPN") { - const { plans, activationFees } = await catalogService.getVpnCatalog(); - - const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null; - if (!plan) { - throw new Error( - `VPN plan not found for reference: ${planRef}. Please go back and select a valid plan.` - ); - } - - items.push({ ...plan, quantity: 1, itemType: "vpn" }); - - const activation = - activationFees.find( - fee => fee.sku === selections.activationSku || fee.id === selections.activationSku - ) ?? null; - if (activation) { - items.push({ ...activation, quantity: 1, itemType: "activation" }); - } - } else { - throw new Error("Unsupported order type. Please begin checkout from the catalog."); - } + // Build cart using BFF service + const cart = await checkoutService.buildCart(orderType, selections, simConfig || undefined); if (!mounted) return; - const totals = calculateTotals(items); - const configuration = - orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations); - setCheckoutState( - createSuccessState({ - items, - totals, - configuration, - }) - ); + setCheckoutState(createSuccessState(cart)); } catch (error) { if (mounted) { const reason = error instanceof Error ? error.message : "Failed to load checkout data"; @@ -282,114 +135,29 @@ export function useCheckout() { if (checkoutState.status !== "success") { throw new Error("Checkout data not loaded"); } + + const cart = checkoutState.data; const uniqueSkus = Array.from( new Set( - checkoutState.data.items + cart.items .map(item => item.sku) .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) ) ); + if (uniqueSkus.length === 0) { throw new Error("No products selected for order. Please go back and select products."); } - let configurationAccumulator: Partial = {}; - - if (orderType === ORDER_TYPE.SIM) { - if (simConfig) { - configurationAccumulator = { ...simConfig }; - } else { - configurationAccumulator = { - ...(selections.simType - ? { simType: selections.simType as OrderConfigurations["simType"] } - : {}), - ...(selections.activationType - ? { - activationType: - selections.activationType as OrderConfigurations["activationType"], - } - : {}), - ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), - ...(selections.eid ? { eid: selections.eid } : {}), - ...(selections.isMnp ? { isMnp: selections.isMnp } : {}), - ...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}), - ...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}), - ...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}), - ...(selections.mvnoAccountNumber - ? { mvnoAccountNumber: selections.mvnoAccountNumber } - : {}), - ...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}), - ...(selections.portingFirstName - ? { portingFirstName: selections.portingFirstName } - : {}), - ...(selections.portingLastNameKatakana - ? { portingLastNameKatakana: selections.portingLastNameKatakana } - : {}), - ...(selections.portingFirstNameKatakana - ? { portingFirstNameKatakana: selections.portingFirstNameKatakana } - : {}), - ...(selections.portingGender - ? { - portingGender: selections.portingGender as OrderConfigurations["portingGender"], - } - : {}), - ...(selections.portingDateOfBirth - ? { portingDateOfBirth: selections.portingDateOfBirth } - : {}), - }; - } - } else { - configurationAccumulator = { - ...(selections.accessMode - ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } - : {}), - ...(selections.activationType - ? { activationType: selections.activationType as OrderConfigurations["activationType"] } - : {}), - ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), - }; - } - - if (confirmedAddress) { - configurationAccumulator.address = { - street: confirmedAddress.address1 ?? undefined, - streetLine2: confirmedAddress.address2 ?? undefined, - city: confirmedAddress.city ?? undefined, - state: confirmedAddress.state ?? undefined, - postalCode: confirmedAddress.postcode ?? undefined, - country: confirmedAddress.country ?? undefined, - }; - } - - const hasConfiguration = Object.keys(configurationAccumulator).length > 0; - const configurations = hasConfiguration - ? orderConfigurationsSchema.parse(configurationAccumulator) - : undefined; + // Validate cart before submission + await checkoutService.validateCart(cart); const orderData = { orderType, skus: uniqueSkus, - ...(configurations ? { configurations } : {}), + ...(Object.keys(cart.configuration).length > 0 ? { configurations: cart.configuration } : {}), }; - if (orderType === ORDER_TYPE.SIM) { - if (!configurations) { - throw new Error( - "SIM configuration is incomplete. Please restart the SIM configuration flow." - ); - } - if (configurations?.simType === "eSIM" && !configurations.eid) { - throw new Error( - "EID is required for eSIM activation. Please go back and provide your EID." - ); - } - if (!configurations?.mnpPhone) { - throw new Error( - "Phone number is required for SIM activation. Please go back and provide a phone number." - ); - } - } - // Client-side guard: prevent Internet orders if an Internet subscription already exists if (orderType === "Internet" && Array.isArray(activeSubs)) { const hasActiveInternet = activeSubs.some( @@ -414,7 +182,7 @@ export function useCheckout() { } finally { setSubmitting(false); } - }, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]); + }, [checkoutState, orderType, activeSubs, router]); const confirmAddress = useCallback((address?: Address) => { setAddressConfirmed(true); diff --git a/apps/portal/src/features/checkout/services/checkout.service.ts b/apps/portal/src/features/checkout/services/checkout.service.ts new file mode 100644 index 00000000..c43ca574 --- /dev/null +++ b/apps/portal/src/features/checkout/services/checkout.service.ts @@ -0,0 +1,26 @@ +import { apiClient } from "@/lib/api"; +import type { CheckoutCart, OrderConfigurations } from "@customer-portal/domain/orders"; + +export const checkoutService = { + /** + * Build checkout cart from order type and selections + */ + async buildCart( + orderType: string, + selections: Record, + configuration?: OrderConfigurations + ): Promise { + return apiClient.POST("/checkout/cart", { + orderType, + selections, + configuration, + }); + }, + + /** + * Validate checkout cart + */ + async validateCart(cart: CheckoutCart): Promise { + await apiClient.POST("/checkout/validate", cart); + }, +}; diff --git a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx index 34b128a7..bb208ba8 100644 --- a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx @@ -9,11 +9,7 @@ import { InlineToast } from "@/components/atoms/inline-toast"; import { StatusPill } from "@/components/atoms/status-pill"; import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation"; import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit"; -import { - ExclamationTriangleIcon, - ShieldCheckIcon, - CreditCardIcon, -} from "@heroicons/react/24/outline"; +import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline"; export function CheckoutContainer() { const { diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 4a0e1b74..0e6d95f8 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -25,8 +25,6 @@ import { useDashboardSummary } from "@/features/dashboard/hooks"; import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components"; import { LoadingStats, LoadingTable } from "@/components/atoms"; import { ErrorState } from "@/components/atoms/error-state"; -import { Formatting } from "@customer-portal/domain/toolkit"; - import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; import { log } from "@customer-portal/logging"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 9db2f14a..e2721127 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -1,13 +1,11 @@ import Link from "next/link"; import { Logo } from "@/components/atoms/logo"; import { - ArrowPathIcon, UserIcon, SparklesIcon, CreditCardIcon, Cog6ToothIcon, PhoneIcon, - ChartBarIcon, } from "@heroicons/react/24/outline"; export function PublicLandingView() { diff --git a/apps/portal/src/features/marketing/views/PublicLandingView.tsx b/apps/portal/src/features/marketing/views/PublicLandingView.tsx index 9db2f14a..e2721127 100644 --- a/apps/portal/src/features/marketing/views/PublicLandingView.tsx +++ b/apps/portal/src/features/marketing/views/PublicLandingView.tsx @@ -1,13 +1,11 @@ import Link from "next/link"; import { Logo } from "@/components/atoms/logo"; import { - ArrowPathIcon, UserIcon, SparklesIcon, CreditCardIcon, Cog6ToothIcon, PhoneIcon, - ChartBarIcon, } from "@heroicons/react/24/outline"; export function PublicLandingView() { diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx index 90edffc0..b9bd0715 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx @@ -1,10 +1,8 @@ "use client"; import { forwardRef } from "react"; -import Link from "next/link"; import { format } from "date-fns"; import { - ServerIcon, CheckCircleIcon, ExclamationTriangleIcon, ClockIcon, diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 9b5d1e97..9381fd69 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -4,8 +4,7 @@ */ import { useQuery } from "@tanstack/react-query"; -import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api"; -import { getNullableData } from "@/lib/api/response-helpers"; +import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api"; import { useAuthSession } from "@/features/auth/services"; import type { InvoiceList } from "@customer-portal/domain/billing"; import { @@ -19,15 +18,6 @@ interface UseSubscriptionsOptions { status?: string; } -const emptyInvoiceList: InvoiceList = { - invoices: [], - pagination: { - page: 1, - totalItems: 0, - totalPages: 0, - }, -}; - /** * Hook to fetch all subscriptions */ diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index df3cf060..6e595256 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -15,7 +15,6 @@ import { XCircleIcon, CalendarIcon, DocumentTextIcon, - ArrowTopRightOnSquareIcon, } from "@heroicons/react/24/outline"; import { format } from "date-fns"; import { useSubscription } from "@/features/subscriptions/hooks"; diff --git a/packages/domain/catalog/contract.ts b/packages/domain/catalog/contract.ts index 0bdf8d9d..c92d7422 100644 --- a/packages/domain/catalog/contract.ts +++ b/packages/domain/catalog/contract.ts @@ -33,6 +33,33 @@ export interface SalesforceProductFieldMap { vpnRegion: string; } +// ============================================================================ +// Pricing and Filter Types +// ============================================================================ + +/** + * Pricing tier for display purposes + */ +export interface PricingTier { + name: string; + price: number; + billingCycle: 'Monthly' | 'Onetime' | 'Annual'; + description?: string; + features?: string[]; + isRecommended?: boolean; + originalPrice?: number; +} + +/** + * Catalog filtering options + */ +export interface CatalogFilter { + category?: string; + priceMin?: number; + priceMax?: number; + search?: string; +} + // ============================================================================ // Re-export Types from Schema (Schema-First Approach) // ============================================================================ diff --git a/packages/domain/catalog/index.ts b/packages/domain/catalog/index.ts index 4334259a..e82ede7e 100644 --- a/packages/domain/catalog/index.ts +++ b/packages/domain/catalog/index.ts @@ -7,7 +7,11 @@ */ // Provider-specific types -export { type SalesforceProductFieldMap } from "./contract"; +export { + type SalesforceProductFieldMap, + type PricingTier, + type CatalogFilter, +} from "./contract"; // Schemas (includes derived types) export * from "./schema"; diff --git a/packages/domain/catalog/schema.ts b/packages/domain/catalog/schema.ts index 873c3305..99ed6cae 100644 --- a/packages/domain/catalog/schema.ts +++ b/packages/domain/catalog/schema.ts @@ -120,6 +120,20 @@ export const vpnCatalogCollectionSchema = z.object({ export const vpnCatalogResponseSchema = vpnCatalogCollectionSchema; +// ============================================================================ +// Catalog Filter Schema +// ============================================================================ + +/** + * Schema for catalog filtering options + */ +export const catalogFilterSchema = z.object({ + category: z.string().optional(), + priceMin: z.number().optional(), + priceMax: z.number().optional(), + search: z.string().optional(), +}); + // ============================================================================ // Inferred Types from Schemas (Schema-First Approach) // ============================================================================ diff --git a/packages/domain/common/schema.ts b/packages/domain/common/schema.ts index 4f69f850..13325107 100644 --- a/packages/domain/common/schema.ts +++ b/packages/domain/common/schema.ts @@ -53,6 +53,33 @@ export const subscriptionBillingCycleEnum = z.enum([ "Free", ]); +// ============================================================================ +// Salesforce and SOQL Validation Schemas +// ============================================================================ + +/** + * Schema for validating Salesforce Account IDs + */ +export const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required"); + +/** + * Schema for validating Salesforce IDs (15 or 18 characters) + */ +export const salesforceIdSchema = z + .string() + .regex(/^[a-zA-Z0-9]{15,18}$/, "Invalid Salesforce ID format") + .trim(); + +/** + * Schema for validating non-empty strings + */ +export const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim(); + +/** + * Schema for validating SOQL field names + */ +export const soqlFieldNameSchema = z.string().trim().regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name"); + // ============================================================================ // API Response Schemas // ============================================================================ diff --git a/packages/domain/common/validation.ts b/packages/domain/common/validation.ts index ee3555ae..84d4c522 100644 --- a/packages/domain/common/validation.ts +++ b/packages/domain/common/validation.ts @@ -18,16 +18,6 @@ export const uuidSchema = z.string().uuid(); */ export const requiredStringSchema = z.string().min(1, "This field is required").trim(); -/** - * Salesforce ID schema (18 characters, alphanumeric) - * Used for Account IDs, Order IDs, etc. - */ -export const salesforceIdSchema = z - .string() - .length(18, "Salesforce ID must be 18 characters") - .regex(/^[A-Za-z0-9]+$/, "Salesforce ID must be alphanumeric") - .trim(); - /** * Customer number / account number schema * Generic schema for customer/account identifiers diff --git a/packages/domain/orders/contract.ts b/packages/domain/orders/contract.ts index 13d8b0a0..5daedeb2 100644 --- a/packages/domain/orders/contract.ts +++ b/packages/domain/orders/contract.ts @@ -8,6 +8,8 @@ import type { SalesforceProductFieldMap } from "../catalog/contract"; import type { SalesforceAccountFieldMap } from "../customer/index"; import type { UserIdMapping } from "../mappings/contract"; +import type { SalesforceOrderRecord } from "./providers/salesforce/raw.types"; +import type { OrderConfigurations } from "./schema"; // ============================================================================ // Order Type Constants @@ -115,6 +117,61 @@ export type OrderType = string; */ export type UserMapping = Pick; +// ============================================================================ +// Checkout Types +// ============================================================================ + +/** + * Individual item in checkout cart + */ +export interface CheckoutItem { + id: string; + sku: string; + name: string; + description?: string; + monthlyPrice?: number; + oneTimePrice?: number; + quantity: number; + itemType: 'plan' | 'installation' | 'addon' | 'activation' | 'vpn'; + autoAdded?: boolean; +} + +/** + * Checkout totals calculation + */ +export interface CheckoutTotals { + monthlyTotal: number; + oneTimeTotal: number; +} + +/** + * Complete checkout cart with items, totals, and configuration + */ +export interface CheckoutCart { + items: CheckoutItem[]; + totals: CheckoutTotals; + configuration: OrderConfigurations; +} + +/** + * Order creation response from BFF + */ +export interface OrderCreateResponse { + sfOrderId: string; + status: string; + message: string; +} + +/** + * Order fulfillment validation result + */ +export interface OrderFulfillmentValidationResult { + sfOrder: SalesforceOrderRecord; + clientId: number; + isAlreadyProvisioned: boolean; + whmcsOrderId?: string; +} + // ============================================================================ // Re-export Types from Schema (Schema-First Approach) // ============================================================================ diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index dc6bac9a..ae972805 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -13,6 +13,12 @@ export { type OrderType, type OrderTypeValue, type UserMapping, + // Checkout types + type CheckoutItem, + type CheckoutTotals, + type CheckoutCart, + type OrderCreateResponse, + type OrderFulfillmentValidationResult, // Constants ORDER_TYPE, ORDER_STATUS, diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index aa671d0c..c98c9471 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -216,6 +216,51 @@ export const sfOrderIdParamSchema = z.object({ export type SfOrderIdParam = z.infer; +// ============================================================================ +// Checkout Schemas +// ============================================================================ + +/** + * Schema for individual checkout items + */ +export const checkoutItemSchema = z.object({ + id: z.string(), + sku: z.string(), + name: z.string(), + description: z.string().optional(), + monthlyPrice: z.number().optional(), + oneTimePrice: z.number().optional(), + quantity: z.number().positive(), + itemType: z.enum(['plan', 'installation', 'addon', 'activation', 'vpn']), + autoAdded: z.boolean().optional(), +}); + +/** + * Schema for checkout totals + */ +export const checkoutTotalsSchema = z.object({ + monthlyTotal: z.number(), + oneTimeTotal: z.number(), +}); + +/** + * Schema for complete checkout cart + */ +export const checkoutCartSchema = z.object({ + items: z.array(checkoutItemSchema), + totals: checkoutTotalsSchema, + configuration: orderConfigurationsSchema, +}); + +/** + * Schema for order creation response + */ +export const orderCreateResponseSchema = z.object({ + sfOrderId: z.string(), + status: z.string(), + message: z.string(), +}); + // ============================================================================ // Inferred Types from Schemas (Schema-First Approach) // ============================================================================