refactor: module reorganization
- Move sim-orders.controller.ts into sim-orders/ sub-directory with barrel file - Extract checkout session business logic from orders controller into checkout-session.service.ts
This commit is contained in:
parent
6e51012d21
commit
1e2ff96313
@ -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")
|
||||
|
||||
@ -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<OrderCreateResponse> {
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
1
apps/bff/src/modules/subscriptions/sim-orders/index.ts
Normal file
1
apps/bff/src/modules/subscriptions/sim-orders/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./sim-orders.controller.js";
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user