Cash on Delivery in an Online Checkout: Taking Orders When Nothing Is Paid Online

How we built cash on delivery into our e-commerce platform: the two-step checkout, transaction statuses, readable errors, price and stock checks, and security.

Key Takeaways

  • Our e-commerce platform takes payments through Stripe and PayPal. For markets those providers don't serve, such as Lebanon, we added two payment methods of our own: cash on delivery and Whish Money.
  • Cash on delivery plugs into the platform's standard two-step checkout, like any other payment method. The order is placed online; the transaction stays open until the cash is collected.
  • The hard parts were the transaction statuses, errors customers can act on, and re-checking prices and stock at the moment of ordering.
  • Amounts always come from the server-side cart at today's prices, never from the request, and a transaction can only be settled once.
  • Its first deployment is shoporganyc.com, New Tripolis Pharma's Organyc store in Lebanon, which takes cash on delivery only.

FNB Solutions has its own e-commerce platform: catalogue, variants, carts, checkout, orders and the content management behind them. Card and wallet payments go through Stripe and PayPal. Those providers don't cover every market. Stripe's list of supported countries, for example, doesn't include Lebanon (Stripe, Global availability). For those markets we built two payment methods into the platform: cash on delivery, and Whish Money, a Lebanese payment app licensed by the Banque du Liban.

This write-up covers cash on delivery, using shoporganyc.com as the example (case study).

The constraint: a checkout where nothing is paid

The platform's checkout has two steps, and every payment method implements both:

  1. Initiate payment. The cart is loaded from the database and validated, and the payment method starts a payment with its provider and records a transaction.
  2. Confirm order. The payment method confirms the payment went through, and the platform creates the order, marks the cart purchased and lowers inventory, in one database transaction.

With a card or a wallet, money changes hands between those two steps. With cash on delivery, it changes hands days later, at the customer's door. The order has to be placed straight away, but the transaction has to stay open until the delivery partner hands over the cash.

How it works

StepWhat happensTransaction status
InitiateThe customer, the delivery address, today's prices and stock, and the amount limits are checked, then a transaction is recorded with a snapshot of the items and address. Nothing is charged.pending
ConfirmThe transaction is checked against this cart and this customer. The order is created, the cart marked purchased and inventory lowered.processing
CollectWhen the cash is handed over, an admin ticks Cash collected. The collection date is saved. Unticking it undoes both.succeeded
AbandonIf the customer starts checkout again on the same cart, earlier unconfirmed transactions are closed.expired

Each status has one meaning: processing means the order is placed and the cash isn't collected yet, which is what the shop's team needs to see each day.

Five problems we had to solve

1. Placed orders couldn't stay pending

The first version left placed orders in pending, since no money had arrived. That worked, but only by accident: the checkout settles pending transactions, so a single detail was all that stopped a placed order from being settled a second time.

Moving placed orders to processing removed that dependency. Only pending transactions with no order can be settled, and they're claimed atomically, so even two confirmations racing each other can't settle one twice. The orders already placed on shoporganyc.com were moved to processing by a data-only migration.

2. Customers saw "Error initiating payment."

The checkout's error handling was designed for card payments: any failure from a payment method became one generic message, Error initiating payment. For cash on delivery, most failures are things the customer can fix: a missing building number, a phone number, a pin on the map.

For those problems, the payment method now returns the reason in a field the checkout already reads, and a small helper turns it back into a message:

ts
const problem = getCashOnDeliveryError(error) // { code, message, fields?, ids? } or null if (problem?.code === 'PriceChanged') await refreshCart() showError(problem?.message ?? 'Your order could not be placed.')

The checkout now shows the real reason, for example "The delivery address is missing: building." There are 14 error codes, shared between the server and the browser, each with an English message that can be replaced on either side.

3. Prices and stock are checked again at the moment of ordering

A cart's total is stored when the cart is saved. If a price changes after a customer adds an item, the stored total is out of date. And stock has to be checked for each variant (a pack size, for example), not only for the product.

So cash on delivery doesn't trust the stored cart. It prices every item at today's price, and checks stock for every product and every variant. If prices changed, it saves the cart with the new total and refuses the order with PriceChanged, so the customer checks the cart before ordering.

4. Admin edge cases

"Cash collected" is a checkbox, and checkboxes get unticked by mistake. Unticking it puts the transaction back to processing and clears the date. It works for updates through the API as well as the admin panel. It can't be ticked on a transaction that has no order.

5. Guests

Guests can order without an account. A guest's email has to match the email on the cart, and a guest who logs in between the two steps can still finish the order, provided the email is the same. Guest orders get a private link to view them.

Security

  • Amounts and items always come from the server-side cart and current prices, never from the request.
  • Only a fixed list of address fields is stored, as text or numbers.
  • Confirming needs the transaction ID and the matching cart and customer.
  • A transaction can only be settled once.

Configurable for each store

Cash on delivery is a self-contained payment method, so a store can offer it alone, as shoporganyc.com does, or next to Stripe, PayPal or Whish Money. Each store configures it:

OptionWhat it does
calculateAmountAdds delivery fees, handling fees or discounts to the total
minimumAmount / maximumAmountLimits on the order total
allowedCountriesCountries the store delivers to
requiredAddressFields, extraAddressFields, requirePhone, requireLocationWhat a delivery address must contain, including a map pin
orderStatusStatus given to new orders
expireAbandonedTurns the closing of abandoned checkouts on or off
messagesTranslates or rewrites any customer-facing error

On shoporganyc.com, cash on delivery requires a map pin, an area, a building and a floor, so the delivery partner can find the exact door; the postcode is optional. The pin also generates a Google Maps link for the delivery partner.

Frequently asked questions

When does cash on delivery make sense? When card payment providers don't serve your business's country, as here, or when your customers prefer to pay when the order arrives. It can also sit next to card and wallet payments, so customers choose.

Can it add a delivery fee? Yes, through calculateAmount, which receives the subtotal, the cart and the delivery address and returns the amount to collect.

Does it change how orders and stock work? No. It uses the platform's standard two-step checkout, so orders, carts and inventory behave exactly as they do with Stripe or PayPal.


FNB Solutions builds and runs online stores on its own e-commerce platform, with Stripe, PayPal, cash on delivery and Whish Money. See the shoporganyc.com case study, our custom web application development service, or tell us about your project.

Sources