diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 38e51867..b1f0ac4b 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -9,7 +9,6 @@ import { Sse, UseGuards, UnauthorizedException, - ConflictException, type MessageEvent, } from "@nestjs/common"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; @@ -24,14 +23,12 @@ import { sfOrderIdParamSchema, orderDetailsSchema, orderListResponseSchema, - type CreateOrderRequest, } from "@customer-portal/domain/orders"; import { Observable } from "rxjs"; import { OrderEventsService } from "./services/order-events.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; import { CheckoutSessionService } from "./services/checkout-session.service.js"; -import { OrderIdempotencyService } from "./services/order-idempotency.service.js"; class CreateOrderRequestDto extends createZodDto(createOrderRequestSchema) {} class CheckoutSessionCreateOrderDto extends createZodDto(checkoutSessionCreateOrderRequestSchema) {} @@ -46,7 +43,6 @@ export class OrdersController { constructor( private orderOrchestrator: OrderOrchestrator, private readonly checkoutSessions: CheckoutSessionService, - private readonly orderIdempotency: OrderIdempotencyService, private readonly orderEvents: OrderEventsService, private readonly logger: Logger ) {} @@ -79,59 +75,7 @@ export class OrdersController { "Order creation from checkout session request received" ); - // Check for existing result (idempotent replay for retries/double-clicks) - const existingResult = await this.orderIdempotency.getExistingResult(body.checkoutSessionId); - if (existingResult) { - return existingResult; - } - - // Acquire lock to prevent concurrent processing - const lockAcquired = await this.orderIdempotency.acquireLock(body.checkoutSessionId); - if (!lockAcquired) { - throw new ConflictException("Order creation already in progress for this session"); - } - - try { - const session = await this.checkoutSessions.getSession(body.checkoutSessionId); - - // Use stored cart - prices were locked at session creation time - // This prevents catalog/price changes mid-checkout from affecting the order - const cart = session.cart; - - const uniqueSkus = [ - ...new Set( - cart.items - .map(item => item.sku) - .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) - ), - ]; - - if (uniqueSkus.length === 0) { - throw new NotFoundException("Checkout session contains no items"); - } - - const orderBody: CreateOrderRequest = { - orderType: session.request.orderType, - skus: uniqueSkus, - ...(Object.keys(cart.configuration ?? {}).length > 0 - ? { configurations: cart.configuration } - : {}), - }; - - const result = await this.orderOrchestrator.createOrder(req.user.id, orderBody); - - // Store result BEFORE deleting session (ensures idempotency on network failure) - await this.orderIdempotency.storeResult(body.checkoutSessionId, result); - - // Safe to delete session now - result is cached for retries - await this.checkoutSessions.deleteSession(body.checkoutSessionId); - - return result; - } catch (error) { - // Release lock on failure to allow immediate retry - await this.orderIdempotency.releaseLock(body.checkoutSessionId); - throw error; - } + return this.checkoutSessions.createOrderFromSession(req.user.id, body.checkoutSessionId); } @Get("user") diff --git a/apps/bff/src/modules/orders/services/checkout-session.service.ts b/apps/bff/src/modules/orders/services/checkout-session.service.ts index cbb02c40..b206eb6c 100644 --- a/apps/bff/src/modules/orders/services/checkout-session.service.ts +++ b/apps/bff/src/modules/orders/services/checkout-session.service.ts @@ -1,8 +1,15 @@ -import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { Inject, Injectable, NotFoundException, ConflictException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { randomUUID } from "crypto"; import { CacheService } from "@bff/infra/cache/cache.service.js"; -import type { CheckoutBuildCartRequest, CheckoutCart } from "@customer-portal/domain/orders"; +import type { + CheckoutBuildCartRequest, + CheckoutCart, + CreateOrderRequest, + OrderCreateResponse, +} from "@customer-portal/domain/orders"; +import { OrderIdempotencyService } from "./order-idempotency.service.js"; +import { OrderOrchestrator } from "./order-orchestrator.service.js"; type CheckoutSessionRecord = { request: CheckoutBuildCartRequest; @@ -18,6 +25,8 @@ export class CheckoutSessionService { constructor( private readonly cache: CacheService, + private readonly orderIdempotency: OrderIdempotencyService, + private readonly orderOrchestrator: OrderOrchestrator, @Inject(Logger) private readonly logger: Logger ) {} @@ -58,6 +67,71 @@ export class CheckoutSessionService { await this.cache.del(key); } + /** + * Create an order from a checkout session with idempotency guarantees. + * + * Handles the full flow: idempotency check, lock acquisition, session + * retrieval, SKU extraction, order creation, result caching, and session cleanup. + */ + async createOrderFromSession( + userId: string, + checkoutSessionId: string + ): Promise { + // Check for existing result (idempotent replay for retries/double-clicks) + const existingResult = await this.orderIdempotency.getExistingResult(checkoutSessionId); + if (existingResult) { + return existingResult; + } + + // Acquire lock to prevent concurrent processing + const lockAcquired = await this.orderIdempotency.acquireLock(checkoutSessionId); + if (!lockAcquired) { + throw new ConflictException("Order creation already in progress for this session"); + } + + try { + const session = await this.getSession(checkoutSessionId); + + // Use stored cart - prices were locked at session creation time + // This prevents catalog/price changes mid-checkout from affecting the order + const cart = session.cart; + + const uniqueSkus = [ + ...new Set( + cart.items + .map(item => item.sku) + .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) + ), + ]; + + if (uniqueSkus.length === 0) { + throw new NotFoundException("Checkout session contains no items"); + } + + const orderBody: CreateOrderRequest = { + orderType: session.request.orderType, + skus: uniqueSkus, + ...(Object.keys(cart.configuration ?? {}).length > 0 + ? { configurations: cart.configuration } + : {}), + }; + + const result = await this.orderOrchestrator.createOrder(userId, orderBody); + + // Store result BEFORE deleting session (ensures idempotency on network failure) + await this.orderIdempotency.storeResult(checkoutSessionId, result); + + // Safe to delete session now - result is cached for retries + await this.deleteSession(checkoutSessionId); + + return result; + } catch (error) { + // Release lock on failure to allow immediate retry + await this.orderIdempotency.releaseLock(checkoutSessionId); + throw error; + } + } + private buildKey(sessionId: string): string { return `${this.keyPrefix}:${sessionId}`; } diff --git a/apps/bff/src/modules/subscriptions/sim-orders/index.ts b/apps/bff/src/modules/subscriptions/sim-orders/index.ts new file mode 100644 index 00000000..58483aea --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-orders/index.ts @@ -0,0 +1 @@ +export * from "./sim-orders.controller.js"; diff --git a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts b/apps/bff/src/modules/subscriptions/sim-orders/sim-orders.controller.ts similarity index 91% rename from apps/bff/src/modules/subscriptions/sim-orders.controller.ts rename to apps/bff/src/modules/subscriptions/sim-orders/sim-orders.controller.ts index 25bd0f45..50e9e299 100644 --- a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-orders/sim-orders.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Post, Request, Headers } from "@nestjs/common"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; -import { SimOrderActivationService } from "./sim-order-activation.service.js"; +import { SimOrderActivationService } from "../sim-order-activation.service.js"; import { createZodDto } from "nestjs-zod"; import { simOrderActivationRequestSchema } from "@customer-portal/domain/sim"; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts index 507c8a4e..b8d544a7 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts @@ -2,7 +2,7 @@ import { Module } from "@nestjs/common"; import { SubscriptionsController } from "./subscriptions.controller.js"; import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js"; import { SimUsageStoreService } from "./sim-usage-store.service.js"; -import { SimOrdersController } from "./sim-orders.controller.js"; +import { SimOrdersController } from "./sim-orders/sim-orders.controller.js"; import { SimOrderActivationService } from "./sim-order-activation.service.js"; import { SecurityModule } from "@bff/core/security/security.module.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";