refactor: module reorganization

This commit is contained in:
barsa 2026-02-24 11:58:17 +09:00
commit 13f1bdc658
5 changed files with 80 additions and 61 deletions

View File

@ -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")

View File

@ -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}`;
}

View File

@ -0,0 +1 @@
export * from "./sim-orders.controller.js";

View File

@ -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";

View File

@ -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";