Skip to content

Luồng Checkout

1. Tổng quan

CheckoutService xử lý chuyển đổi DRAFT → PROCESSING và chiều ngược lại (hoàn tác). Tổng đơn hàng (net, tax, discount, total) không được tính lại khi checkout — chúng được duy trì bởi SaleOrderRepository.updateSummaryFromItems() mỗi khi mặt hàng được thêm, xóa, hoặc cập nhật. Checkout chỉ xác thựccập nhật trạng thái.

Mã nguồn: src/services/checkout.service.ts

typescript
export class CheckoutService extends BaseService {
  constructor(
    @inject({
      key: BindingKeys.build({
        namespace: BindingNamespaces.REPOSITORY,
        key: SaleOrderRepository.name,
      }),
    })
    private readonly _saleOrderRepository: SaleOrderRepository,
    @inject({
      key: BindingKeys.build({
        namespace: BindingNamespaces.SERVICE,
        key: SaleSocketEventService.name,
      }),
    })
    private readonly _saleSocketEventService: SaleSocketEventService,
  ) {
    super({ scope: CheckoutService.name });
  }
}

2. Checkout (DRAFT → PROCESSING)

Method: checkout(opts: { request: { orderId: string; note?: string }; userId?: string }): Promise<TSaleOrder>

Bước 1: Tìm đơn hàng với mặt hàng

typescript
const order = await this._saleOrderRepository.findOne({
  filter: {
    where: { id: orderId, status: SaleOrderStatuses.DRAFT },
    fields: ['id', 'total', 'merchantId'],
    include: [{
      relation: 'items',
      scope: { fields: ['id', 'quantity', 'unitPrice'] },
    }],
  },
});

Truy vấn lọc trực tiếp theo status: DRAFT — nếu đơn hàng tồn tại nhưng không ở DRAFT, trả về null. Cũng lấy merchantId để lưu vào metadata.

Bước 2: Xác thực

Xác thựcKiểm traLỗi
Đơn hàng tồn tại và ở DRAFT!order → 404Order not found or not in DRAFT status
Giỏ hàng không rỗng!orderItems?.length → 400Cannot checkout empty cart
Giá không âmNumber(item.unitPrice) < 0 → 400Invalid price for item ${item.id}: unitPrice cannot be negative
Số lượng dươngNumber(item.quantity) < 1 → 400Invalid quantity for item ${item.id}: quantity must be at least 1
typescript
for (const item of orderItems) {
  if (Number(item.unitPrice) < 0) {
    throw getError({
      message: `Invalid price for item ${item.id}: unitPrice cannot be negative`,
      statusCode: HTTP.ResultCodes.RS_4.BadRequest,
    });
  }
  if (Number(item.quantity) < 1) {
    throw getError({
      message: `Invalid quantity for item ${item.id}: quantity must be at least 1`,
      statusCode: HTTP.ResultCodes.RS_4.BadRequest,
    });
  }
}

NOTE

Xác thực giá cho phép unitPrice = 0 (mặt hàng miễn phí). Chỉ giá âm bị từ chối. Điều này khác với tài liệu trước đó nêu rằng price > 0 là bắt buộc.

Bước 3: Cập nhật trạng thái và lưu Metadata

typescript
const { note, finance } = request;

const { data: updatedOrder } = await this._saleOrderRepository.updateById({
  id: orderId,
  data: {
    status: SaleOrderStatuses.PROCESSING,
    processingAt: new Date(),
    metadata: {
      merchantId: order.merchantId,
      note,
      finance,
    },
  },
});

Không sử dụng transaction — checkout là một cập nhật atomic duy nhất. Tổng đơn hàng không được tính lại ở đây (chúng đã được duy trì bởi updateSummaryFromItems khi mặt hàng thay đổi).

Trường metadata lưu ngữ cảnh checkout cho các dịch vụ downstream:

Thuộc tínhKiểuMô tả
merchantIdstringTham chiếu merchant cho tương quan giữa các dịch vụ
notestring?Ghi chú checkout tùy chọn từ người dùng
financeobjectCấu hình tài chính: { use: false } hoặc { use: true, walletId, categoryId }

Biểu đồ chuỗi

3. Hoàn tác Checkout (PROCESSING → DRAFT)

Method: revertCheckout(opts: { orderId: string }): Promise<TSaleOrder>

typescript
async revertCheckout(opts: { orderId: string }): Promise<TSaleOrder> {
  const order = await this._saleOrderRepository.findOne({
    filter: { where: { id: orderId }, fields: ['id', 'status'] },
  });

  if (!order) {
    throw getError({ message: 'Order not found', statusCode: 404 });
  }

  if (!SaleOrderStatuses.canRevertToCart(order.status)) {
    throw getError({ message: 'Cannot revert checkout for this order', statusCode: 400 });
  }

  const { data: updatedOrder } = await this._saleOrderRepository.updateById({
    id: orderId,
    data: { status: SaleOrderStatuses.DRAFT },
  });

  // Fire-and-forget thông báo WS đến phòng quan sát
  this._saleSocketEventService.notifyOrderUpdate({ order: updatedOrder });

  return updatedOrder;
}
Quy tắcChi tiết
Trạng thái có thể hoàn tácChỉ trạng thái mà SaleOrderStatuses.canRevertToCart() trả về true (PROCESSING)
Kiểm tra checkNếu đơn hàng có check đang hoạt động (checkSplitAt đã set), hoàn tác sẽ bị chặn — cần hoàn tác check trước. Xem Tách hóa đơn
Dấu thời gianprocessingAt không bị xóa — hoàn tác chỉ đổi status về DRAFT
Mặt hàng giữ nguyênTất cả mặt hàng không bị ảnh hưởng — người dùng có thể thêm/xóa/sửa lại
Tổng giữ nguyênTổng đơn hàng giữ như đã tính

4. Hủy đơn hàng

Mã nguồn: SaleOrderService.cancelOrder() (dòng 238–286 trong sale.service.ts)

Hủy được xử lý bởi SaleOrderService, không phải CheckoutService:

typescript
async cancelOrder(opts: { orderId: string; reason?: string; userId?: string }) {
  const tx = await this._saleOrderRepository.beginTransaction();
  try {
    const order = await this._saleOrderRepository.findOne({
      filter: { where: { id: orderId }, fields: ['id', 'status'] },
      options: { transaction: tx },
    });

    if (SaleOrderStatuses.isTerminal(order.status)) {
      throw getError({ message: 'Cannot cancel order with terminal status', statusCode: 400 });
    }

    const { data: updatedOrder } = await this._saleOrderRepository.updateById({
      id: orderId,
      data: {
        status: SaleOrderStatuses.CANCELLED,
        cancelledAt: new Date(),
        cancellationReason: reason,
      },
      options: { transaction: tx },
    });

    await tx.commit();
    return updatedOrder;
  } catch (err) {
    await tx.rollback();
    throw err;
  }
}
Quy tắcChi tiết
Có thể hủy từDRAFT, PROCESSING, PARTIAL (trạng thái không phải cuối)
Không thể hủyCOMPLETED, CANCELLED (trạng thái cuối)
Dùng transactionKhác với checkout, hủy sử dụng transaction tường minh
Lý docancellationReason tùy chọn lưu trên đơn hàng

5. Xóa mặt hàng

Mã nguồn: SaleOrderService.clearOrderItems() (dòng 181–235 trong sale.service.ts)

typescript
async clearOrderItems(opts: { saleOrderId: string }) {
  // Bọc trong transaction:
  // 1. Xác thực đơn hàng tồn tại và trạng thái cho phép chỉnh sửa
  // 2. Xóa tất cả mặt hàng (xóa cứng qua deleteAll)
  // 3. Tính lại tổng đơn hàng (reset về 0)
}
Quy tắcChi tiết
Cho phép từChỉ DRAFT (SaleOrderStatuses.canModifyItems())
Kiểu xóaXóa cứng — deleteAll({ where: { saleOrderId } })
Cập nhật tổngupdateSummaryFromItems() tính lại tổng (reset về 0)

6. Route Controller

Mã nguồn: src/controllers/sale/definitions.ts

Route KeyMethodPathAuthRequest Body
CREATE_DRAFTPOST/draftJWT, BasicCreateDraftOrderRequestSchema
ADD_SALE_ORDER_ITEMPOST/{id}/itemsJWT, BasicAddItemRequestSchema (discriminated union)
CLEAR_ITEMSDELETE/{id}/itemsJWT, Basic
CHECKOUTPOST/{id}/checkoutJWT, BasicCheckoutRequestSchema
REVERTPOST/{id}/revertJWT, Basic
CANCELPOST/{id}/cancelJWT, BasicCancelOrderRequestSchema

Cộng thêm các route CRUD tiêu chuẩn kế thừa từ ControllerFactory.defineCrudController().

7. Schema phản hồi

Mã nguồn: src/models/responses/sale.model.ts

CheckoutRequest

typescript
const CheckoutRequestSchema = z.object({
  note: z.string().max(1000).optional(),
  finance: z.object({ use: z.literal(false) })
    .or(z.object({
      use: z.literal(true),
      walletId: z.string(),
      categoryId: z.string(),
    })),
});

Trường financediscriminated union bắt buộc:

Biến thểTrườngKhi nào sử dụng
{ use: false }Không cóBỏ qua ghi nhận tài chính (mặc định cho POS)
{ use: true, walletId, categoryId }walletId + categoryId bắt buộcGhi nhận thu nhập vào module Finance khi thanh toán thành công

Dữ liệu finance được lưu trong metadata.finance của SaleOrder và được đưa vào payload Kafka payment.success (payment.finance) mà Finance Service tiêu thụ khi thanh toán thành công.

CheckoutResponse

typescript
const CheckoutResponseSchema = z.object({
  order: z.object({ id, orderNumber, status, processingAt }),
  source: z.object({ type: 'ORDER', id, uid }),  // Cho MQ-Pay
  totals: z.object({ subtotal, discount, tax, total, currency, itemCount }),
  items: z.array(z.object({ id, mode, itemType, itemId, productMetadata, quantity, unitPrice, total, displayName })),
});

RevertCheckoutResponse

typescript
const RevertCheckoutResponseSchema = z.object({
  success: z.boolean(),
  cart: z.object({ id, status }),
  order: z.object({ id, orderNumber, status }).optional(),
  message: z.string().optional(),
});

8. Tài liệu liên quan

Tài liệuMô tả
Sale OrderCấu trúc thực thể, chế độ mặt hàng, vòng đời trạng thái
Tích hợp Thanh toánXử lý sự kiện thanh toán sau checkout
Tách hóa đơnHệ thống SaleCheck — tách hóa đơn, thao tác check, kiểm tra checkSplitAt
Thao tác Đơn hàngGộp đơn hàng, hoàn tác gộp, tách đơn hàng
Tổng quan Dịch vụ SaleKiến trúc, component, binding key

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.