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,
|
Sse,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
|
||||||
type MessageEvent,
|
type MessageEvent,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
|
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
|
||||||
@ -24,14 +23,12 @@ import {
|
|||||||
sfOrderIdParamSchema,
|
sfOrderIdParamSchema,
|
||||||
orderDetailsSchema,
|
orderDetailsSchema,
|
||||||
orderListResponseSchema,
|
orderListResponseSchema,
|
||||||
type CreateOrderRequest,
|
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
import { OrderEventsService } from "./services/order-events.service.js";
|
import { OrderEventsService } from "./services/order-events.service.js";
|
||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.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 { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||||
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
||||||
import { OrderIdempotencyService } from "./services/order-idempotency.service.js";
|
|
||||||
|
|
||||||
class CreateOrderRequestDto extends createZodDto(createOrderRequestSchema) {}
|
class CreateOrderRequestDto extends createZodDto(createOrderRequestSchema) {}
|
||||||
class CheckoutSessionCreateOrderDto extends createZodDto(checkoutSessionCreateOrderRequestSchema) {}
|
class CheckoutSessionCreateOrderDto extends createZodDto(checkoutSessionCreateOrderRequestSchema) {}
|
||||||
@ -46,7 +43,6 @@ export class OrdersController {
|
|||||||
constructor(
|
constructor(
|
||||||
private orderOrchestrator: OrderOrchestrator,
|
private orderOrchestrator: OrderOrchestrator,
|
||||||
private readonly checkoutSessions: CheckoutSessionService,
|
private readonly checkoutSessions: CheckoutSessionService,
|
||||||
private readonly orderIdempotency: OrderIdempotencyService,
|
|
||||||
private readonly orderEvents: OrderEventsService,
|
private readonly orderEvents: OrderEventsService,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -79,59 +75,7 @@ export class OrdersController {
|
|||||||
"Order creation from checkout session request received"
|
"Order creation from checkout session request received"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for existing result (idempotent replay for retries/double-clicks)
|
return this.checkoutSessions.createOrderFromSession(req.user.id, body.checkoutSessionId);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("user")
|
@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 { Logger } from "nestjs-pino";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
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 = {
|
type CheckoutSessionRecord = {
|
||||||
request: CheckoutBuildCartRequest;
|
request: CheckoutBuildCartRequest;
|
||||||
@ -18,6 +25,8 @@ export class CheckoutSessionService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
|
private readonly orderIdempotency: OrderIdempotencyService,
|
||||||
|
private readonly orderOrchestrator: OrderOrchestrator,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -58,6 +67,71 @@ export class CheckoutSessionService {
|
|||||||
await this.cache.del(key);
|
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 {
|
private buildKey(sessionId: string): string {
|
||||||
return `${this.keyPrefix}:${sessionId}`;
|
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 { Body, Controller, Post, Request, Headers } from "@nestjs/common";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
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 { createZodDto } from "nestjs-zod";
|
||||||
import { simOrderActivationRequestSchema } from "@customer-portal/domain/sim";
|
import { simOrderActivationRequestSchema } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@ -2,7 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { SubscriptionsController } from "./subscriptions.controller.js";
|
import { SubscriptionsController } from "./subscriptions.controller.js";
|
||||||
import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js";
|
import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js";
|
||||||
import { SimUsageStoreService } from "./sim-usage-store.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 { SimOrderActivationService } from "./sim-order-activation.service.js";
|
||||||
import { SecurityModule } from "@bff/core/security/security.module.js";
|
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user