Skip to main content
Version: Latest
Technical ManualLooking for step-by-step instructions? In the User Manual: Point of sale

Point of Sale

FeatureCrestApps.OrchardCore.PointOfSale (Point of Sale)
CategoryCommerce
Depends onCommerce, Orders, Inventory, Checkout API
Works withReceipts (printed and emailed receipts), Customers (a named buyer), Gift Cards, Stripe (Terminal readers)

The Point of Sale feature sells at a store's counter from a till app over the commerce API. A sale is an order on the ordinary checkout framework, so prices, coupons, gift cards, tax, stock holds, order events and reports work as they do online. The module adds what a counter needs: registers, shifts with their cash, a cash tender, a payment method for the store's own card terminal, receipts, and returns at the till.

Model​

Registers and shifts live in the PointOfSale YesSql collection.

  • Register (RegisterIndex): Name, LocationId (an inventory location), Address (an Address, which sales are taxed by and billed to), Currency (empty sells in the products' own currency), Roles (empty lets anyone who may sell at a register), IsEnabled, CreatedUtc, ModifiedUtc.
  • RegisterShift (RegisterShiftIndex): RegisterId, RegisterName, LocationId, Currency, Status (Open or Closed), who opened and closed it and when, OpeningFloat, CashMovements (each a RegisterCashMovement: Amount, positive in and negative out, Reason, who and when), and at the close CountedCash, ExpectedCash, CloseNote and Summary.
  • RegisterShiftSummary: Sales, Total, Cash (kept: handed over less change), Card, Other (gift cards and other balances) and Refunded (refunds of the shift's sales). PosShiftService.SummarizeAsync works it out from the shift's paid orders (OrderQuery.ShiftId); a closed shift keeps it.

RegisterShift.GetExpectedCash(cashKept) is the float, plus the cash kept for sales, plus the cash movements.

Orders and the checkout​

PosSaleService.StartAsync prices the lines as a cart (ICartService.PriceAsync, without saving a cart), and makes the order with IOrderService.CreateFromCartAsync, setting:

Order fieldValue
ChannelOrderChannels.PointOfSale (pos)
LocationIdThe register's location, which Inventory holds and sells the stock at (InventoryOrderEventHandler, falling back to the main location for an item the store has none of)
RegisterId, RegisterName, ShiftIdThe register and its open shift
StaffUserId, StaffNameWho rang it up
CustomerId, UserId, customer name, email and phoneThe buyer, when one is named
BillingAddressThe register's address

It commits that, then starts the order's checkout with ICheckoutEngine.StartAsync and puts on the session:

  • CheckoutCounterSale (RegisterId, ShiftId, LocationId), which marks it a counter sale;
  • a CheckoutShippingInfo for an In store rate of zero at the register's address, so the invoice is taxed where it's sold;
  • the buyer's CheckoutContactInfo, when they have an email.

OrderChannels.IsCounterSale(order) makes the Customers' Contact step and Shipping's Shipping step drop out. Store credit and loyalty points aren't offered for a counter sale. The session belongs to the signed-in staff member who started it.

Paying​

PosSaleService.PayAsync pays in this order:

  1. Cash. PosCashTenderProvider (ICheckoutTenderProvider, key cash) is a balance whose reference is the cash handed over in the currency's minor units (PosCashTenderProvider.ToReference). It pays up to what's due, after tax like a gift card, and only on a session with CheckoutCounterSale. The change is what was handed over less what the cash tenders paid (PosSaleService.GetChange).
  2. A card, for what's left: PosCounterCardPaymentProvider (ICheckoutPaymentProvider and ICheckoutPaymentRefundProvider, key pos-card, Card on the counter terminal) records a card the store's own terminal charged; its reference is the approval code given (pos_card_ and the code), and it verifies as paid at once. A refund through it records the refund; staff make it on the terminal. Or StripeTerminalPaymentProvider (key stripe-terminal, below), given a reader's id.
  3. Completion. ICheckoutEngine.TryCompleteAsync. A reader payment answers pending until Stripe reports the PaymentIntent succeeded.

Receipts​

PosReceipts.ToApi gives a receipt as data (ApiPosReceipt: lines, totals, payments, change) in the completed payment's answer. With the Receipts feature on, PosReceipts.RenderAsync builds an IReceiptService document (source Point of sale, the payments in its notes) and renders it with IReceiptHtmlRenderer, for GET pos/sales/{orderId}/receipt and its email through IEmailService.

Returns​

POST pos/returns refunds a paid order through OrderOperationsService.RefundAsync (to how it was paid, restocking at the order's location unless restock is false). The refund goes to the card first and to the sale's cash last, so the cash handed back is what the refund put back on the sale's cash tenders (read from ICheckoutTenderLedger before and after): it's recorded as a negative cash movement on the register's open shift, which a return requires. Only register sales are returned at a till, unless the caller has Manage orders.

API​

PosApiEndpoints maps these into the commerce JSON API at api/commerce/v1, under the tag Point of Sale. Each needs a signed-in caller who may sell at the register concerned (PosShiftService.MaySellAtAsync: the register is on, the caller has Sell at a register, and is in one of its roles or has Manage registers and shifts); another register answers 404.

MethodPathWhat it does
GETpos/registersThe registers the caller may sell at (ApiRegister, with openShiftId).
POSTpos/registers/{registerId}/shiftsOpens a shift (openingFloat); 409 shift-open when one is open. Idempotent.
GETpos/shifts/{shiftId}The shift (ApiShift), with its summary and expected cash.
POSTpos/shifts/{shiftId}/cashA cash movement (amount, reason); 409 shift-closed. Idempotent.
POSTpos/shifts/{shiftId}/closeCloses it with countedCash and note.
POSTpos/salesRings up lines (PosSaleLine: productId, variantId, priceId, quantity, addOns) for a customerId, or a customerEmail and customerName; 201 with orderId, orderNumber and the checkout (ApiCheckout); 400 on customerId when it isn't a customer, 409 shift-closed without an open shift. Idempotent.
POSTpos/sales/{checkoutId}/paycash, then card (approvalCode) or reader (readerId); answers ApiPosPayment with status completed, due or pending, amountDue, change and, once completed, receipt; {} asks whether a reader's payment is done. 409 shift-closed once the sale's shift has closed; 409 not-available for a declined reader payment. Idempotent.
GETpos/sales/{orderId}/receiptThe receipt's HTML; 409 unsupported without Receipts.
POSTpos/sales/{orderId}/receipt/emailEmails it to email; 204.
POSTpos/returnsorderId, registerId, amount, reason, restock; answers refunded, cashBack and the refund's messages. Idempotent.

Codes and gift cards go on the sale's checkout through the checkout endpoints, and DELETE checkout/{id} cancels an unpaid sale.

Stripe Terminal​

With the Stripe feature and Point of Sale both on, Stripe's TerminalStartup adds StripeTerminalPaymentProvider (ICheckoutPaymentProvider and ICheckoutPaymentRefundProvider, key stripe-terminal, Card on a Stripe reader), offered only for a session with CheckoutCounterSale. It uses Stripe's server-driven integration, so the till app needs no Stripe SDK:

  • Begin creates a card_present PaymentIntent for the attempt's amount and tax (idempotency key terminal-pi- and the attempt id, or the attempt's existing PaymentIntent when begun again) and tells the reader in ProviderData["readerId"] to process it.
  • Verify reads the PaymentIntent: succeeded is paid, canceled failed, anything else still waiting.
  • Cancel cancels the PaymentIntent; refund goes through IStripeRefundService.

StripeTerminalApiEndpoints adds, under the same tag:

MethodPathPermissionWhat it does
GETpos/terminal/readersSell at a registerLists the account's readers (ApiStripeReader: id, label, deviceType, status, locationId, action, actionStatus).
POSTpos/terminal/readersManage registers and shiftsPairs a reader by registrationCode, with a label, at stripeLocationId or a Stripe location made from address. simulated-wpe pairs a simulated reader in test mode.
POSTpos/terminal/readers/{readerId}/test-cardSell at a registerIn test mode, presents a test card on a simulated reader (TestHelpers.Terminal.ReaderService.PresentPaymentMethodAsync); 409 unsupported on a live account.

Pair a reader​

A manager pairs a reader once, by the registration code it shows when it's set up:

POST pos/terminal/readers
Content-Type: application/json

{
"registrationCode": "sunny-river-moon",
"label": "Downtown till 1",
"address": { "addressLine1": "1 Main Street", "city": "Springfield", "region": "IL", "postalCode": "62701", "country": "US" }
}

Stripe keeps each reader at a Stripe location: give the store's address and one is made for it, or name one made in the Stripe Dashboard with stripeLocationId. The answer is the reader:

{ "id": "tmr_...", "label": "Downtown till 1", "deviceType": "bbpos_wisepos_e", "status": "online", "locationId": "tml_...", "action": null, "actionStatus": null }

GET pos/terminal/readers lists the readers, so the till app can let staff pick the one at their register. In test mode, the registration code simulated-wpe pairs a simulated reader, and POST pos/terminal/readers/{readerId}/test-card presents a test card on it while a sale is waiting.

A shift, call by call​

A shift as a till app makes it. Paths are shown without https://your-site/api/commerce/v1/, every call carries the staff member's bearer token, and the answers leave out some fields. Send an Idempotency-Key on the calls that take one (see Idempotency keys), so a dropped connection never rings a sale up twice.

1. Pick a register and open a shift​

GET pos/registers
[
{ "id": "4r9k2x7m1q", "name": "Downtown till 1", "locationId": "downtown", "currency": "USD", "address": { "city": "Springfield", "country": "US" }, "openShiftId": null }
]

The list has only the registers the staff member may sell at. openShiftId names the register's open shift, when it has one: carry on with it. Otherwise open one with the cash put in the drawer, the float:

POST pos/registers/4r9k2x7m1q/shifts
Content-Type: application/json

{ "openingFloat": 100.00 }

The answer is 201 Created with the shift. A register has one open shift at a time; opening another answers 409 with the code shift-open.

2. Ring up a sale​

POST pos/sales
Content-Type: application/json

{
"registerId": "4r9k2x7m1q",
"lines": [ { "productId": "4b2kx7m9qz1c", "quantity": 2 } ],
"customerEmail": "avery@example.com",
"customerName": "Avery Brooks"
}

Each line is a product, with variantId, priceId and addOns as for adding it to a cart. Name the buyer with customerId when staff look them up, so the sale is on their account and earns them points (an id that isn't a customer answers 400); or give a walk-in buyer's customerEmail and customerName, for their receipt; or neither.

The answer is 201 Created:

{
"orderId": "4d1k8s0p2w",
"orderNumber": "1043",
"checkout": {
"id": "7c2m9x1k",
"status": "pending",
"totals": {
"subtotal": { "amount": 24.0, "currency": "USD" },
"tax": { "amount": 1.98, "currency": "USD" },
"total": { "amount": 25.98, "currency": "USD" },
"amountDue": { "amount": 25.98, "currency": "USD" }
}
}
}

The sale is an order, with its stock held at the register's store while it's paid. A sale can't be rung up without an open shift (409, shift-closed), and a product that can't be sold, or has run out, answers 409 with the code not-available and what's wrong.

A discount code or a gift card goes on the sale's checkout with the checkout endpoints: POST checkout/7c2m9x1k/codes or POST checkout/7c2m9x1k/gift-cards. Store credit and loyalty points aren't spent at a register. To give up on a sale before it's paid, DELETE checkout/7c2m9x1k cancels its checkout: the order is canceled and its stock let go.

The sale's checkout belongs to the staff member who rang it up, so the same account pays it.

3. Take the payment​

Pay with the cash the buyer hands over:

POST pos/sales/7c2m9x1k/pay
Content-Type: application/json

{ "cash": 30.00 }
{
"status": "completed",
"amountDue": { "amount": 0.0, "currency": "USD" },
"change": { "amount": 4.02, "currency": "USD" },
"receipt": {
"orderNumber": "1043",
"staff": "jordan",
"lines": [ { "description": "Contoso mug", "quantity": 2, "unitPrice": { "amount": 12.0, "currency": "USD" }, "total": { "amount": 24.0, "currency": "USD" } } ],
"total": { "amount": 25.98, "currency": "USD" },
"payments": [ { "method": "Cash", "amount": { "amount": 25.98, "currency": "USD" } } ],
"change": { "amount": 4.02, "currency": "USD" }
}
}
statusWhat it means
completedPaid, and the order is placed. Hand back change, and print the receipt.
dueamountDue is still to pay: take more cash, or a card.
pendingA Stripe reader is waiting for the card. Ask again in a couple of seconds by paying with {}.

A sale is paid in the shift it was rung up in: once that shift has closed, paying answers 409 with the code shift-closed, and the sale is rung up again on the open shift.

When more cash is handed over than is due, the rest is the change. Send all the cash the buyer hands over in one cash. Some cash and a card for the rest is one call, or two:

POST pos/sales/7c2m9x1k/pay

{ "cash": 20.00, "card": { "approvalCode": "483920" } }

card records a card taken on the counter terminal, for what's left after the cash; take it on the terminal first. reader sends what's left to a Stripe reader instead:

POST pos/sales/7c2m9x1k/pay

{ "reader": { "readerId": "tmr_..." } }

The answer is pending until the buyer presents their card; then paying again with {} answers completed. If the card is declined, paying answers 409 with the code not-available: send the payment to the reader again, or take another way to pay.

4. The receipt​

The answer that completes the sale holds the receipt as data, for a till app that prints in its own layout on a receipt printer. With the Receipts feature on:

  • GET pos/sales/4d1k8s0p2w/receipt gives the receipt as a printable HTML page, branded from the Receipts settings;
  • POST pos/sales/4d1k8s0p2w/receipt/email with { "email": "avery@example.com" } emails it.

Without Receipts, both answer 409 with the code unsupported.

5. Cash in and out​

Cash put in the drawer or taken out outside a sale, such as more change from the safe or a banking run, is recorded on the shift: a positive amount is put in, a negative one taken out.

POST pos/shifts/9s4m2k7x/cash
Content-Type: application/json

{ "amount": -200.00, "reason": "Banked at noon" }

6. Returns at the till​

POST pos/returns
Content-Type: application/json

{ "orderId": "4d1k8s0p2w", "registerId": "4r9k2x7m1q", "reason": "Wrong size", "restock": true }
{
"orderId": "4d1k8s0p2w",
"orderNumber": "1043",
"refunded": { "amount": 25.98, "currency": "USD" },
"cashBack": { "amount": 25.98, "currency": "USD" },
"messages": [ "..." ]
}

Leave out amount to refund all of it, or give an amount to refund part. The items go back on the shelf at the register's store unless restock is false. What the sale paid in cash is handed back from the drawer, cashBack, and recorded as cash taken out of the register's open shift, so a return needs an open shift (409, shift-closed). When a sale was paid partly in cash and partly by card, the card is refunded first and only what it doesn't cover comes out of the drawer. A till returns its store's register sales; an online order is returned there only by staff with Manage orders. The rest goes back to how it was paid: a Stripe reader's card is refunded through Stripe, and a gift card gets its money back. A card taken on the counter terminal is refunded on that terminal: the site records the refund, and staff make it on the terminal.

7. Close the shift​

At the end of the shift, count the cash in the drawer, and close it:

POST pos/shifts/9s4m2k7x/close
Content-Type: application/json

{ "countedCash": 124.50, "note": "Short 1.48, a coin dropped behind the till" }
{
"id": "9s4m2k7x",
"registerName": "Downtown till 1",
"status": "closed",
"openingFloat": { "amount": 100.0, "currency": "USD" },
"sales": 1,
"total": { "amount": 25.98, "currency": "USD" },
"cash": { "amount": 25.98, "currency": "USD" },
"card": { "amount": 0.0, "currency": "USD" },
"expectedCash": { "amount": 125.98, "currency": "USD" },
"countedCash": { "amount": 124.5, "currency": "USD" },
"difference": { "amount": -1.48, "currency": "USD" }
}

GET pos/shifts/9s4m2k7x gives the same at any time while it's open, so the till can show how the day is going.

Admin​

  • Commerce > Point of sale > Registers (point-of-sale/registers, RegistersAdminController) lists registers through the AdminList shape, with Add register and each register's editor.
  • Commerce > Point of sale > Shifts (point-of-sale/shifts) lists shifts, newest first, with a register filter in the list toolbar; a shift's page shows its sales by tender and its cash drawer (float, put in less taken out, expected, counted, difference), its cash movements and its close note.

Permissions​

PermissionAllows
Sell at a register (SellAtRegister)The point-of-sale API at the registers the user's roles may use: shifts, sales, payments, receipts and returns.
Manage registers and shifts (ManageRegisters)The registers and shifts admin pages, selling at every register, and pairing Stripe readers.

Administrators have both by default.

  • Carts and Orders: the order's channel, location, register and shift.
  • Inventory: the locations registers sell from.
  • Checkout: the checkout a sale is paid through, and balances that pay after tax.
  • Payments: Stripe Terminal readers.
  • Receipts: the receipt's layout.