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ực và cập nhật trạng thái.
Mã nguồn: src/services/checkout.service.ts
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
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ực | Kiểm tra | Lỗi |
|---|---|---|
| Đơn hàng tồn tại và ở DRAFT | !order → 404 | Order not found or not in DRAFT status |
| Giỏ hàng không rỗng | !orderItems?.length → 400 | Cannot checkout empty cart |
| Giá không âm | Number(item.unitPrice) < 0 → 400 | Invalid price for item ${item.id}: unitPrice cannot be negative |
| Số lượng dương | Number(item.quantity) < 1 → 400 | Invalid quantity for item ${item.id}: quantity must be at least 1 |
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
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ính | Kiểu | Mô tả |
|---|---|---|
merchantId | string | Tham chiếu merchant cho tương quan giữa các dịch vụ |
note | string? | Ghi chú checkout tùy chọn từ người dùng |
finance | object | Cấ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>
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ắc | Chi tiết |
|---|---|
| Trạng thái có thể hoàn tác | Chỉ trạng thái mà SaleOrderStatuses.canRevertToCart() trả về true (PROCESSING) |
| Kiểm tra check | Nế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 gian | processingAt không bị xóa — hoàn tác chỉ đổi status về DRAFT |
| Mặt hàng giữ nguyên | Tấ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ên | Tổ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:
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ắc | Chi tiết |
|---|---|
| Có thể hủy từ | DRAFT, PROCESSING, PARTIAL (trạng thái không phải cuối) |
| Không thể hủy | COMPLETED, CANCELLED (trạng thái cuối) |
| Dùng transaction | Khác với checkout, hủy sử dụng transaction tường minh |
| Lý do | cancellationReason 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)
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ắc | Chi tiết |
|---|---|
| Cho phép từ | Chỉ DRAFT (SaleOrderStatuses.canModifyItems()) |
| Kiểu xóa | Xóa cứng — deleteAll({ where: { saleOrderId } }) |
| Cập nhật tổng | updateSummaryFromItems() tính lại tổng (reset về 0) |
6. Route Controller
Mã nguồn: src/controllers/sale/definitions.ts
| Route Key | Method | Path | Auth | Request Body |
|---|---|---|---|---|
CREATE_DRAFT | POST | /draft | JWT, Basic | CreateDraftOrderRequestSchema |
ADD_SALE_ORDER_ITEM | POST | /{id}/items | JWT, Basic | AddItemRequestSchema (discriminated union) |
CLEAR_ITEMS | DELETE | /{id}/items | JWT, Basic | — |
CHECKOUT | POST | /{id}/checkout | JWT, Basic | CheckoutRequestSchema |
REVERT | POST | /{id}/revert | JWT, Basic | — |
CANCEL | POST | /{id}/cancel | JWT, Basic | CancelOrderRequestSchema |
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
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 finance là discriminated union bắt buộc:
| Biến thể | Trường | Khi 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ộc | Ghi 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
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
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ệu | Mô tả |
|---|---|
| Sale Order | Cấu trúc thực thể, chế độ mặt hàng, vòng đời trạng thái |
| Tích hợp Thanh toán | Xử lý sự kiện thanh toán sau checkout |
| Tách hóa đơn | Hệ thống SaleCheck — tách hóa đơn, thao tác check, kiểm tra checkSplitAt |
| Thao tác Đơn hàng | Gộp đơn hàng, hoàn tác gộp, tách đơn hàng |
| Tổng quan Dịch vụ Sale | Kiến trúc, component, binding key |