Skip to content

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):

InjectPurpose
SaleOrderRepositoryorder load + status update
SaleOrderItemRepositoryitem snapshot writes
SaleSocketEventServiceWS broadcast on transition
PricingNetworkServiceHTTP client → @nx/pricing for v1+v2 calculate
ShiftServicevalidateAndAttachSession when shift management enabled

2. Checkout (DRAFT → PROCESSING)

Method signature:

ts
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

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'] },
    }],
  },
});

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

ValidationCheckError
Order exists and is DRAFT!order → 404Order not found or not in DRAFT status
Non-empty cart!orderItems?.length → 400Cannot checkout empty cart
Non-negative pricesNumber(item.unitPrice) < 0 → 400Invalid price for item ${item.id}: unitPrice cannot be negative
Positive quantitiesNumber(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

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

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,
    },
  },
});

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:

PropertyTypeDescription
merchantIdstringMerchant reference for cross-service correlation
notestring?Optional checkout note from the user
financeobjectFinance configuration: { use: false } or { use: true, walletId, categoryId }

Sequence Diagram

3. Revert 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 WS notification to observation rooms
  this._saleSocketEventService.notifyOrderUpdate({ order: updatedOrder });

  return updatedOrder;
}
RuleDetail
Revertible statusesOnly statuses where SaleOrderStatuses.canRevertToCart() returns true (PROCESSING)
Check guardIf the order has active checks (checkSplitAt is set), revert should be blocked — rollback checks first. See Check Splitting
TimestampprocessingAt is not cleared — the revert only changes status back to DRAFT
Items preservedAll items remain untouched — user can add/remove/modify again
Totals preservedOrder totals remain as calculated

4. Cancel Order

Source: SaleOrderService.cancelOrder() (lines 238–286 in sale.service.ts)

Cancel is handled by SaleOrderService, not 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;
  }
}
RuleDetail
Cancellable fromDRAFT, PROCESSING, PARTIAL (any non-terminal status)
Not cancellableCOMPLETED, CANCELLED (terminal statuses)
Uses transactionUnlike checkout, cancel uses an explicit transaction
ReasonOptional cancellationReason stored on the order

5. Clear Items

Source: SaleOrderService.clearOrderItems() (lines 181–235 in sale.service.ts)

typescript
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)
}
RuleDetail
Allowed fromDRAFT only (SaleOrderStatuses.canModifyItems())
Delete typeHard delete — deleteAll({ where: { saleOrderId } })
Summary updateupdateSummaryFromItems() recalculates totals (resets to 0)

6. Controller Routes

Source: 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

Plus standard CRUD routes inherited from ControllerFactory.defineCrudController().

7. Response Schemas

Source: 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(),
    })),
});

The finance field is a required discriminated union:

VariantFieldsWhen to Use
{ use: false }NoneSkip finance recording (default for POS)
{ use: true, walletId, categoryId }walletId + categoryId requiredRecord 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

typescript
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

typescript
const RevertCheckoutResponseSchema = z.object({
  success: z.boolean(),
  cart: z.object({ id, status }),
  order: z.object({ id, orderNumber, status }).optional(),
  message: z.string().optional(),
});
DocumentDescription
Sale OrderEntity structure, item modes, status lifecycle
Payment IntegrationWhat happens after checkout — payment event handling
Check SplittingSaleCheck system — bill splitting, check operations, checkSplitAt guard
Order OperationsOrder merge, merge rollback, order split
Sale Service OverviewArchitecture, components, binding keys

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