A store's problems are easiest to read in the API's answers, not on the page. The cart and checkout calls return the whole record, with its messages and totals, so the first check for most of these is to make the same call yourself and read what came back. And because orders cannot be deleted and have no test flag, reproduce anything that needs a purchase on a test site, never on the live one.
Commerce
Eight store problems, from a cart that empties to a payment with no order, each with its causes in the order they usually turn out to be true and the API response that tells them apart.
The cart empties between pages
The cart is found through one cookie, cart. A cart that "empties" is nearly always a browser that stopped sending it, so compare the cookie in the browser's developer tools before and after it happens. In order:
- A different hostname. The cookie is set for the exact hostname, so
example.comandwww.example.comhave separate carts. Redirect every hostname to one. - The shopper signed out. Sign-out clears the cookie. A member's cart stays on their account and comes back when they sign in.
- The order was placed. Purchased items leave the cart, and a cart left empty is deleted.
- The page was served over plain http. The cookie is always
Secure, so a browser does not keep it from an http page, and every page starts a new cart. Look for this behind a local proxy or on a preview hostname without a certificate. - Another browser, a private window, or cleared cookies. A guest cart then comes back only through its restore link.
- A guest cart expired. It is deleted 180 days after its last change (the Carts Lifetime setting). A member's cart is kept.
Three things look like an empty cart and are not. A count rendered on a cached page shows another visitor's cart (next question). GET /cdna-api/cart answers 404 for a visitor with no saved cart, which is an empty cart, not an error. And items that can no longer be bought stay in the cart marked unavailable, left out of the total, and are not carried into the checkout, which can read as "my items vanished". Show unavailable lines with a remove button (the cart cookie).
The cart count is wrong or stale
- It is rendered on a cached page. A count printed with
$_cartin a site-wide header is stored with the page and served to every later visitor, so they see the first visitor's count. Reading$_cartalso saves an empty cart for every visitor, crawlers included. Render the count in the browser from the API (the cart widget must be a fragment). - It counts the wrong thing. Decide whether the badge shows lines or units, and leave out unavailable items, which are not in the total or the checkout.
- It is not updated after a change. Every cart call returns the whole cart. Update the count from that response, rather than reloading something that may be cached.
- The page came back from the browser's back-forward cache, with the count as it was when the shopper left. Refresh it on
pageshow.
async function refreshCartCount() {
const res = await fetch('/cdna-api/cart', { headers: { Accept: 'application/json' } });
const items = res.ok ? (await res.json()).data.cart.items : []; // 404: no cart yet
const count = items
.filter(item => item.available)
.reduce((total, item) => total + item.quantity, 0);
document.querySelectorAll('[data-cart-count]').forEach(el => { el.textContent = count; });
}
refreshCartCount();
window.addEventListener('pageshow', event => { if (event.persisted) refreshCartCount(); });
A discount code is rejected for no visible reason
Where the code was entered decides how much the shopper is told. On the cart, the message gives the reason. On the checkout, every refusal comes back as "Discount code does not exist", whatever the reason. To find the real one, add the code to the cart (POST /cdna-api/cart/discount/<code>) and read data.cart.messages. Then, in order:
- "Code is not found". The code does not exist on this environment. Discounts are records, so a code created on staging does not exist on production until someone creates it there.
- "Code could not be added as its maximum usage is reached". Two causes are easy to miss:
- Max uses is 0. In the admin, 0 forbids the code entirely. For no limit, leave the field empty.
- Uses are counted from checkouts, not only orders. Every checkout that applied the code holds one use until the checkout ends, which for an abandoned one is 30 minutes after its last change. A code limited to 10 uses can be used up by 10 shoppers who reached the checkout and left. Uses by orders that were later refunded or cancelled still count.
- "This code cannot be applied because another code for the same discount is already in use". Only one code per discount can be applied at a time.
- "Code is not added as it is already added". It is already on the cart. Codes are matched without regard to case, so
SPRINGandspringare the same code.
A code that is accepted and gives no discount is a different case. An expired or not-yet-valid discount, or a cart that does not meet its conditions, adds the code with "Code has been added" and no discount, and a discount whose own maximum uses are reached stops applying the same way, without that message. Either way, compare discountTotal before and after adding a code, and tell the shopper the code "doesn't apply to this order" if it did not change (discounts).
Shipping rates do not appear at checkout
No rates is a success response with an empty list, and a checkout without a selected rate can never be paid. A shipping method that does not apply is left out of the list, so first work through why a method does not appear: the method is inactive or has no shipping categories; a product has no shipping category and no default is set; one product's category is not covered by the method, which removes the method for the whole order; for pickup, the warehouse is not one of the method's; or the method's template failed, or ran without calling rates.create for this order. Then:
- The shipping call answered 500. With tax switched on, creating shipping also calculates tax, so a tax provider that fails or cannot be reached stops shipping from being saved. The failed result is cached for up to an hour (when a provider is down).
- The address is incomplete or in the wrong form.
countryName,addressLine1andpostcodeare required, andcountryNameis the full name,Australia, notAU. A method template that compares country codes matches nothing. - The rates expired. A rate lasts an hour (the Shipping rate Lifetime setting). A shopper who comes back later must have shipping created again.
- Zones were expected to choose the method. Shipping zones are read by nothing. A method meant for one region must check the address in its own template.
- The page reads the rates from the wrong place. Rates belong to a fulfilment: they are at
fulfillments[].shippingRatesin the response.
Tax is calculated as zero
Read the checkout through the API, GET /cdna-api/checkout/<id>, and look at taxTotal and each item's tax. If both are zero, in order:
- Tax is switched off. The Taxes Enabled setting is off by default; switch it on for the site to calculate tax.
- The address the zone setting names was missing when tax was calculated. Tax is calculated when the items, the shipping, the email or the delivery address change, and skipped if the address it needs is not there yet. With Zone resolves to set to the billing address, a checkout that gets its billing address after shipping is created is never taxed, because a billing change on its own does not recalculate. Send the billing address before creating shipping, or send the delivery address again after it.
- The provider is not set up to collect tax there. Avalara charges tax only where the company has nexus configured, and Stripe Tax only where the account has a registration. The provider's own transaction log shows what it was asked and what it answered; the platform stores what the provider returns.
- The tax code is exempt. A tax category maps a product to the provider's tax code. A product with no category, and every variant, uses the provider's default item tax code, and an exempt code gives zero.
- The provider is the Mock provider, with a rate of 0.
- It is a form payment. A payment element on a form always records tax as 0, and its Tax Category setting is never applied (payments in a form).
- The order was imported through the API with its own totals, which are never recalculated.
Tax rate records, the rounding method and the shipping tax category setting have no effect. Entering a rate in the admin does not produce tax.
A payment succeeded but no order exists
The provider shows the money taken, and the site has no order. An order is created when the checkout's invoice is fully paid, so start in the admin, under Invoices, with an invoice for that amount. A paid invoice whose source is a checkout means the payment reached the site; no invoice at all means it did not. Then, in order:
- The payment is still being confirmed. A payment that needs a 3-D Secure challenge, a redirect to the provider, or a confirmation that comes later, such as a bank debit, keeps its checkout, split from the cart, for up to a year (the Checkouts setting Standalone Checkout Lifetime). The order is created when the provider confirms the payment. Check the payment's status in the provider's dashboard, and see The payment-processing page and webhooks.
- Two payment attempts. A retry after the checkout changed opens a new invoice for the new total, and the order comes from the attempt whose invoice is open. When both attempts went through, for example a 3-D Secure challenge finished in another tab, refund the other payment from the provider's dashboard.
- PayPal. A PayPal payment completes inside the purchase call. Compare PayPal's transaction log with the site's orders to find a capture with no order, including one PayPal reports as pending.
- The order number template. Every order needs a number of its own, so the order number template must contain
{SEQ}(Order numbers). - It was a form payment. A payment element on a form creates an invoice and a transaction, never an order.
- The order is on another environment. Staging and production can share a provider account, and each has its own orders.
For the shopper, refund the payment from the provider's dashboard, or create the order through the orders API, and contact them either way. If none of these explains it, contact Core dna support with the invoice, the provider's payment id and the time.
A test payment method is selectable on production
- Mock Pay is active on some site of the account. The payment methods list covers every site of the account, so a storefront that does not filter it shows other sites' methods, including a test site's Mock Pay. Filter the list to the current site or no site, and deactivate the method anyway: an active Mock Pay method anywhere on the account is a risk to every site on it (the mock provider).
- A Stripe or PayPal method is still in Test Mode. Test cards succeed and no money moves. Turn Test Mode off, then place a small real order to confirm the live method works.
- A form offers it. A payment element on a form has its own choice of methods. Check every form that takes payments.
- The list is cached or hardcoded. A payment step printed into a cached page, or built from fixed method ids, still shows a method after it has been deactivated. Fetch the list when the payment step opens.
The check after any change to payment methods: request the live list and confirm that no entry is Mock Pay and none is in test mode.
curl -s "https://www.example.com/cdna-api/invoices/payment-methods?active=1" \
| grep -E '"paymentServiceProviderType": ?"mock-pay"|"testMode": ?true'
No output is the pass. Do the same for tax: no live tax zone, and not the fallback provider, may point at the Mock tax provider, which charges real shoppers a made-up rate.
The order confirmation email did not send
The platform sends no order email of its own. A receipt, a shipping notice and a refund notice are each a job with a Send mail action (transactional emails). So, in order:
- No job was built. The invoice's Send receipt event is not a purchase receipt: it fires only when an administrator presses that button.
- The job did not fire. A receipt job is on Orders, CreateRecord: not on the checkout, which is deleted when the order is created, and not on the invoice. Look in Batch Logs for a batch at the time of the order. No batch means the job never started: it is unpublished, limited to another site, or on the wrong event (my job did not fire).
- The batch failed at Send mail. An invalid recipient address fails the action, and so does an error in the email template. The batch log shows the message, provided the job's batch persistence keeps errors.
- The recipient expression is wrong. A job's record is an API model, and the shopper's address is on the order's person:
{$event->getRecord()->getPerson()->getEmail()}
{{ event.getRecord().getPerson().getEmail() }}
- It was sent to the development address. On any environment other than production, mail goes to the development address unless the recipient matches its whitelist.
- It was sent and filtered as spam. Send from an address on the client's own domain, authenticated with SPF and DKIM.
Building one from the start: send an email when a record is created.
Was this page helpful?
Your answer goes to the docs team.