Checkout Flow
1. Overview
The CheckoutService handles the critical DRAFT → PROCESSING transition and its reverse (revert). Order totals (subtotal, tax, discount, total) are maintained by SaleOrderRepository.updateSummaryFromItems() on item mutations; at checkout, totals are re-priced via PricingNetworkService (v1 + v2) and the snapshot persisted onto the items.
Source: src/services/checkout.service.ts (478 lines).
Constructor injects (5):
| Inject | Purpose |
|---|---|
SaleOrderRepository | order load + status update |
SaleOrderItemRepository | item snapshot writes |
SaleSocketEventService | WS broadcast on transition |
PricingNetworkService | HTTP client → @nx/pricing for v1+v2 calculate |
ShiftService | validateAndAttachSession when shift management enabled |
2. Checkout (DRAFT → PROCESSING)
Method signature:
async checkout(opts: {
request: TCheckoutRequest & { orderId: string };
userId?: string;
deviceId?: string;
transaction?: ITransaction<TAnyDataSourceSchema>;
}): Promise<TSaleOrder>request includes orderId + note? + finance (required discriminated union — see §7). deviceId is read from the x-device-info header for shift-session validation when shift management is enabled for the merchant.
Step 1: Find Order with Items
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'] },
}],
},
});The query filters by status: DRAFT directly — if the order exists but is not DRAFT, it returns null. Also fetches merchantId for metadata storage.
Step 2: Validate
| Validation | Check | Error |
|---|---|---|
| Order exists and is DRAFT | !order → 404 | Order not found or not in DRAFT status |
| Non-empty cart | !orderItems?.length → 400 | Cannot checkout empty cart |
| Non-negative prices | Number(item.unitPrice) < 0 → 400 | Invalid price for item ${item.id}: unitPrice cannot be negative |
| Positive quantities | 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
Price validation allows unitPrice = 0 (free items). Only negative prices are rejected. This differs from the previous documentation which stated price > 0 was required.
Step 3: Update Status and Store 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,
},
},
});No transaction is used — the checkout is a single atomic update. Order totals are not recalculated here (they are already maintained by updateSummaryFromItems when items change).
The metadata field stores checkout context for downstream services:
| Property | Type | Description |
|---|---|---|
merchantId | string | Merchant reference for cross-service correlation |
note | string? | Optional checkout note from the user |
finance | object | Finance configuration: { use: false } or { use: true, walletId, categoryId } |
Sequence Diagram
3. Revert 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 WS notification to observation rooms
this._saleSocketEventService.notifyOrderUpdate({ order: updatedOrder });
return updatedOrder;
}| Rule | Detail |
|---|---|
| Revertible statuses | Only statuses where SaleOrderStatuses.canRevertToCart() returns true (PROCESSING) |
| Check guard | If the order has active checks (checkSplitAt is set), revert should be blocked — rollback checks first. See Check Splitting |
| Timestamp | processingAt is not cleared — the revert only changes status back to DRAFT |
| Items preserved | All items remain untouched — user can add/remove/modify again |
| Totals preserved | Order totals remain as calculated |
4. Cancel Order
Source: SaleOrderService.cancelOrder() (lines 238–286 in sale.service.ts)
Cancel is handled by SaleOrderService, not 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;
}
}| Rule | Detail |
|---|---|
| Cancellable from | DRAFT, PROCESSING, PARTIAL (any non-terminal status) |
| Not cancellable | COMPLETED, CANCELLED (terminal statuses) |
| Uses transaction | Unlike checkout, cancel uses an explicit transaction |
| Reason | Optional cancellationReason stored on the order |
5. Clear Items
Source: SaleOrderService.clearOrderItems() (lines 181–235 in sale.service.ts)
async clearOrderItems(opts: { saleOrderId: string }) {
// Transaction-wrapped:
// 1. Validate order exists and status allows modification
// 2. Delete all items (hard delete via deleteAll)
// 3. Recalculate order summary (resets to 0)
}| Rule | Detail |
|---|---|
| Allowed from | DRAFT only (SaleOrderStatuses.canModifyItems()) |
| Delete type | Hard delete — deleteAll({ where: { saleOrderId } }) |
| Summary update | updateSummaryFromItems() recalculates totals (resets to 0) |
6. Controller Routes
Source: 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 |
Plus standard CRUD routes inherited from ControllerFactory.defineCrudController().
7. Response Schemas
Source: 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(),
})),
});The finance field is a required discriminated union:
| Variant | Fields | When to Use |
|---|---|---|
{ use: false } | None | Skip finance recording (default for POS) |
{ use: true, walletId, categoryId } | walletId + categoryId required | Record income in Finance module on payment success |
This finance data is stored in the SaleOrder's metadata.finance and included in the Kafka payment.success payload (payment.finance) consumed by the Finance Service when payment succeeds.
CheckoutResponse
const CheckoutResponseSchema = z.object({
order: z.object({ id, orderNumber, status, processingAt }),
source: z.object({ type: 'ORDER', id, uid }), // For 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. Related Documentation
| Document | Description |
|---|---|
| Sale Order | Entity structure, item modes, status lifecycle |
| Payment Integration | What happens after checkout — payment event handling |
| Check Splitting | SaleCheck system — bill splitting, check operations, checkSplitAt guard |
| Order Operations | Order merge, merge rollback, order split |
| Sale Service Overview | Architecture, components, binding keys |