AI for reducing cart abandonment and returns
TL;DR
Difficulty: Advanced
Cart abandonment and returns are prediction problems, not only design problems.
- Abandonment: a gradient boosting model scores each checkout session and picks the response that fits the reason, on the page, before the shopper leaves. Judge it on precision and recall, never accuracy.
- Recovery emails are direct marketing: consent, or the ePrivacy exception for your own customers within 12 months of a sale, with an opt out at collection and in every message.
- Returns: score each line at order time (split the data by time), suggest sizes from similar customers who kept the product, and fix the products that keep coming back.
- The business case for an invented €210,000 a month shop: about €5,600 a month in margin from two points less abandonment, and about €3,600 a month from three points fewer returns.
Seven in 10 carts are abandoned, and a large share of what sells comes back. I build two predictive systems from scratch, an abandonment risk scorer and a return risk predictor, in Python with the Solidus side in Rails, and work the business case through on an invented Galway outdoor clothing shop.
Two leaks, and both are prediction problems
Two numbers describe the leakiest parts of nearly every online shop.
The first is cart abandonment. Baymard Institute keep a running average of published studies, and across 50 studies from 2006 to 2025 the documented average is 70.22%.1 Roughly seven in 10 people who put something in a basket leave without paying.
The second is returns. In the US, the National Retail Federation and Happy Returns estimated in December 2024 that retailers expected 16.9% of their annual sales to come back that year, about $890 billion in total.2 In the same report, 51% of Gen Z shoppers said they buy several items with the plan to send some back, which the trade calls bracketing.2 Those are US figures, so treat them as a sense of scale, not a forecast for your shop.
Both leaks look like conversion problems. I think they are better treated as prediction problems. The signals that a shopper is about to leave are already in your checkout events. The signals that an order will come back are already in your order history. The work is asking the question before it happens, not after.
This article builds two systems from scratch: an abandonment risk scorer that spots shoppers about to leave and picks the right response, and a return risk predictor that flags sizing problems and high return products before they ship. The models are in Python, the shop side is Ruby on Rails and Solidus. Everything is shown on an invented shop, no client examples. If the terms classifier, probability score or training set are new to you, AI vs machine learning and logistic regression and lift charts are the gentle way in.
The example shop for this article
Burren Outdoor is an invented outdoor clothing shop in Galway, at burrenoutdoor.example, running on Solidus. Every number about Burren in this article is made up for the example and labelled as such. Rain jackets, fleeces and walking trousers, in sizes XS to XXL, so sizing matters, and in the west of Ireland so does the rain.
Why people abandon, and why it is predictable
Baymard's survey asks US shoppers who abandoned an order in the previous three months why they did. 42% say they were just browsing. Leave those out, and the reasons for the rest are led by extra costs that were too high (40%), delivery that was too slow (20%), not trusting the site with card details (19%), being asked to create an account (18%), a checkout that was too long or complicated (17%), an unsatisfactory returns policy (13%) and not enough payment methods (9%).1 People could give several reasons, so the shares add up to more than 100%. If you want the psychology of the checkout itself, conversion and UX psychology goes into it.
Here is what makes this a machine learning problem as well as a design problem. Different shoppers leave for different reasons, and their behaviour often hints at which one. A shopper who switches between payment methods three times has a payment problem. A shopper who sits on the delivery step for a long time has a delivery cost or delivery date problem. A shopper whose discount code just failed has a price problem.
One thing you cannot see, whatever some guides suggest: whether someone left to check a competitor's price. The browser can tell a page that its tab was hidden, through the Page Visibility API, but not where the shopper went. A hidden tab is a weak signal, not a reason.
The abandonment rate itself
Baymard define the rate as one minus completed purchases divided by carts created:1
Here is the abandonment rate as a percentage, is the number of completed orders and is the number of carts created in the same period. It is a plain rate, events over opportunities, turned around. Foundations: ratios and rates
Worked example: Burren's abandonment rate
In the example month, Burren's shoppers create 10,000 carts and complete 3,000 orders. So .
Burren is bang on the documented average. Normal is still expensive.
Scoring a checkout session for abandonment risk
The model below is a gradient boosting classifier: many small decision trees, each correcting the errors of the ones before, that together output a probability between 0 and 1 that a checkout session ends in abandonment.3 It learns from past sessions where you know the outcome.
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, roc_auc_score, precision_recall_curve
import joblib
FEATURES = [
'cart_value', 'cart_item_count',
'time_on_checkout_seconds', 'payment_method_switches',
'items_removed_during_checkout', 'pages_viewed_before_checkout',
'is_returning_customer', 'previous_abandonment_count',
'device_type', 'hour_of_day', 'delivery_page_time_seconds',
'promo_frustration', 'avg_time_per_item', 'hesitation_score',
]
def add_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['avg_time_per_item'] = df['time_on_checkout_seconds'] / df['cart_item_count'].clip(lower=1)
df['hesitation_score'] = df['payment_method_switches'] + df['items_removed_during_checkout'] * 2
df['promo_frustration'] = ((df['promo_code_attempted'] == 1) & (df['promo_code_valid'] == 0)).astype(int)
return df
def train_abandonment_model(sessions_csv: str):
"""
One row per checkout session, from your own server side checkout events:
cart_value, cart_item_count, time_on_checkout_seconds, payment_method_switches,
items_removed_during_checkout, pages_viewed_before_checkout, is_returning_customer,
previous_abandonment_count, device_type (mobile 1, desktop 0), hour_of_day,
delivery_page_time_seconds, promo_code_attempted, promo_code_valid,
abandoned (the target: 1 abandoned, 0 completed)
"""
df = add_features(pd.read_csv(sessions_csv))
X, y = df[FEATURES], df['abandoned']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
model = GradientBoostingClassifier(
n_estimators=200, max_depth=5, learning_rate=0.1,
subsample=0.8, min_samples_leaf=20
)
model.fit(X_train, y_train)
y_proba = model.predict_proba(X_test)[:, 1]
print(classification_report(y_test, model.predict(X_test)))
print(f"AUC: {roc_auc_score(y_test, y_proba):.3f}")
for feat, imp in sorted(zip(FEATURES, model.feature_importances_), key=lambda x: x[1], reverse=True):
print(f" {feat}: {imp:.3f}")
# precision and recall have one more entry than thresholds, so drop the last one
precision, recall, thresholds = precision_recall_curve(y_test, y_proba)
f1 = 2 * precision[:-1] * recall[:-1] / (precision[:-1] + recall[:-1] + 1e-8)
threshold = thresholds[np.argmax(f1)]
print(f"Intervention threshold: {threshold:.3f}")
joblib.dump(model, 'abandonment_model.pkl')
return model, threshold
def score_live_session(model, session: dict, threshold: float) -> dict:
features = add_features(pd.DataFrame([session]))[FEATURES]
p = float(model.predict_proba(features)[0][1])
return {
'abandonment_probability': round(p, 3),
'risk_level': 'high' if p > threshold else 'medium' if p > threshold * 0.7 else 'low',
'should_intervene': p > threshold,
'suggested_intervention': suggest_intervention(features.iloc[0], p),
}
def suggest_intervention(f, p: float) -> str:
if f['promo_frustration']:
return 'offer_alternative_discount'
if f['delivery_page_time_seconds'] > 30:
return 'highlight_delivery_options'
if f['payment_method_switches'] > 2:
return 'show_payment_help'
if f['is_returning_customer']:
return 'gentle_reminder' # do not train loyal customers to wait for a discount
if f['cart_value'] > 100:
return 'offer_free_delivery'
return 'exit_intent_message'
Two fixes against the version of this code I first published. The precision and recall arrays from scikit learn are one entry longer than the thresholds array, so the threshold search has to drop the last entry or it can pick an index that does not exist.4 And the live scoring now builds the same engineered features as training, from one function, so the model never sees a column it was not trained on.
Which features end up on top depends on your shop. Print the importances and read them, and do not take anyone's list, mine included, as a law.
Accuracy is the wrong number here
With 70% of sessions ending in abandonment, a "model" that says "abandons" every time is 70% accurate and useless. That is the base rate problem. Foundations: indexing and base rates So the code reports precision, recall and their balance, the F1 score, instead:
Here is precision, the share of flagged sessions that really abandon, is recall, the share of real abandoners the model flags, is the number of true positives (flagged and abandoned), the false positives (flagged but completed), the false negatives (abandoned but not flagged), and is the harmonic mean of and . Precision and recall are rates, events over opportunities. Foundations: ratios and rates
Worked example: is Burren's model any good?
Burren's test set has 1,000 sessions, of which 700 abandoned. At the chosen threshold the model flags 600 sessions, and 540 of those really abandoned.
- , , .
- Precision: .
- Recall: .
- .
In plain words: nine in 10 shoppers who see an intervention were genuinely about to leave, and the model catches about three quarters of everyone who leaves. The 60 false positives are shoppers who would have paid anyway. That is why the interventions should be helpful, not discounts. A discount shown to someone who was going to pay is margin given away.
Choosing the response on the shop side
The Python model scores. The Rails side decides what to show and when. I call the model through a small service wrapper with a timeout and a fallback, the pattern I describe in integrating AI into existing workflows, so a slow model never holds up a checkout.
module CartRecovery
class InterventionEngine
INTERVENTIONS = {
offer_alternative_discount: { type: :inline, template: 'checkout/interventions/discount_offer' },
highlight_delivery_options: { type: :inline, template: 'checkout/interventions/delivery_options' },
show_payment_help: { type: :inline, template: 'checkout/interventions/payment_help' },
gentle_reminder: { type: :inline, template: 'checkout/interventions/saved_basket' },
offer_free_delivery: { type: :banner, template: 'checkout/interventions/free_delivery' },
exit_intent_message: { type: :exit_intent, template: 'checkout/interventions/exit_intent' }
}.freeze
def evaluate(session:, order:)
return if session.intervention_shown?
features = SessionFeatureBuilder.build(session, order)
risk = ScoringService.abandonment(features, timeout: 0.3) # nil on timeout or error
return unless risk && risk[:should_intervene]
intervention = INTERVENTIONS.fetch(risk[:suggested_intervention].to_sym)
session.record_intervention!(intervention, risk)
intervention
end
end
class SessionFeatureBuilder
def self.build(session, order)
user = order.user
{
cart_value: order.total.to_f,
cart_item_count: order.line_items.sum(:quantity),
time_on_checkout_seconds: session.checkout_duration_seconds,
payment_method_switches: session.payment_switches_count,
items_removed_during_checkout: session.removals_during_checkout,
pages_viewed_before_checkout: session.page_count,
is_returning_customer: user && user.orders.complete.exists? ? 1 : 0,
previous_abandonment_count: user ? session.previous_abandonments_for(user) : 0,
device_type: session.mobile? ? 1 : 0,
hour_of_day: Time.current.hour,
delivery_page_time_seconds: session.time_on_step(:delivery),
promo_code_attempted: session.promo_attempted? ? 1 : 0,
promo_code_valid: session.promo_valid? ? 1 : 0
}
end
end
end
order.total, order.line_items and user.orders.complete are standard Solidus. The session object is your own: a small model that records checkout events on the server. No email goes out from this engine. Everything it does happens on the page, while the shopper is still there.
Worked example: three Burren shoppers
Three sessions cross the threshold on the same afternoon.
- A first time visitor with a €140 jacket whose code "WINTER10" just failed.
promo_frustrationis 1, so the engine offers the discount that is actually live, if there is one, and says plainly that the old code has ended. - A shopper who has spent 45 seconds on the delivery step with a €60 fleece. The engine shows the delivery options and dates side by side, with the free delivery threshold if Burren has one.
- A returning customer, eight orders in, who has switched payment method three times. The engine shows payment help. Not a discount: she already trusts Burren, the payment step is what is in her way.
Recovery emails, and the permission they need
When a cart is abandoned anyway, a recovery email sequence can bring some of it back. The model's signals decide which sequence.
Before the timing, the permission. A cart reminder promotes your products, so in Ireland it is direct marketing by email, and regulation 13 of the ePrivacy Regulations 2011 applies. You may send it to an individual only if they consented to marketing email from you, or under the exception in regulation 13(11) for your own customers: you got their email address in the context of a sale, the message is about your own products of a kind similar to what they bought, they were given a clear, easy and free way to object when the address was collected and in every message since, and the sale was no more than 12 months ago, unless you have emailed them under the exception within that time.5
Two practical consequences. The opt out has to be on the checkout form where the email address is collected, not only in the email. And a reminder dressed as a "quick question about your basket" is still marketing if its purpose is the sale. The Data Protection Commission (DPC) has looked at that line. An airline emailed a passenger asking for feedback on a recent trip, and the DPC said correspondence "sent solely for informational or feedback purposes does not constitute direct marketing", while one with marketing content would be, and would need an unsubscribe option.6 So plain feedback questions with no offer are a different matter, but the safer course is to send even those only to people who allowed marketing or fall under the regulation 13(11) exception. I wrote about that line in more detail in the marketing automation article linked above.
The sequence starts from a Solidus event. Solidus has a publish and subscribe bus, Spree::Bus, built on the Omnes library. It publishes events such as order_finalized, and you can register your own, ideally with a namespaced name so it never clashes with a future core event.7 Subscribers should not hold up the flow that published the event, so they only enqueue jobs.7
# config/initializers/omnes.rb
Rails.application.config.to_prepare do
Spree::Bus.register(:burren_cart_abandoned) unless Spree::Bus.registry.registered?(:burren_cart_abandoned)
Burren::CartRecoverySubscriber.new.subscribe_to(Spree::Bus)
end
# A recurring job publishes the event for carts with an email and no activity for an hour:
# Spree::Bus.publish(:burren_cart_abandoned, order: order, context: context)
# app/subscribers/burren/cart_recovery_subscriber.rb
module Burren
class CartRecoverySubscriber
include Omnes::Subscriber
handle :burren_cart_abandoned, with: :schedule
def schedule(event)
CartRecovery::EmailSequencer.new.schedule(order: event.payload[:order], context: event.payload[:context])
end
end
end
module CartRecovery
class EmailSequencer
SEQUENCES = {
price_sensitive: [{ wait: 1.hour, template: :reminder }, { wait: 24.hours, template: :current_offers }],
delivery_concerned: [{ wait: 1.hour, template: :delivery_options }],
returning_customer: [{ wait: 2.hours, template: :saved_basket }],
default: [{ wait: 1.hour, template: :reminder }, { wait: 24.hours, template: :reviews_and_fit }]
}.freeze
def schedule(order:, context:)
# The shop's own record: consent, or the 13(11) exception with no objection on file
return unless MarketingPermission.email_allowed?(order.email)
key = sequence_for(context)
SEQUENCES.fetch(key).each_with_index do |step, i|
RecoveryEmailJob.set(wait: step[:wait]).perform_later(
order_id: order.id, template: step[:template], position: i + 1, sequence: key.to_s
)
end
end
private
def sequence_for(context)
return :price_sensitive if context[:promo_frustration] == 1
return :delivery_concerned if context[:delivery_page_time_seconds].to_i > 30
return :returning_customer if context[:is_returning_customer] == 1
:default
end
end
end
class RecoveryEmailJob < ApplicationJob
def perform(order_id:, template:, position:, sequence:)
order = Spree::Order.find(order_id)
return if order.completed? # they came back and paid
return unless MarketingPermission.email_allowed?(order.email) # they may have opted out since
CartRecoveryMailer.with(order: order, template: template, position: position).reminder.deliver_now
end
end
Three things changed against the first version. The permission check uses the shop's own consent record and works for guest checkouts, through order.email. Every job checks again before sending, because the customer may have paid or opted out in the meantime. And the escalating "last chance, 10% off" step is gone. Fake urgency is a bad look, and a discount on the third email teaches regular customers to abandon on purpose.
Worked example: which Burren carts get an email
On a Tuesday, 300 Burren carts go quiet for an hour. 120 have no email address, because the shopper never reached that step. Of the 180 with an address, 110 belong to people who consented, or bought in the last 12 months and never objected. Those 110 get a sequence. The other 70 get nothing, and the right place to win some of them back is the checkout itself, which is what the on page engine is for.
Why returns happen, and which ones you can prevent
Not every return is a failure. A present that does not suit is a normal part of shopping, and a generous returns policy is one of the reasons people buy online at all. But some returns are predictable before the parcel leaves.
For clothing, start with size and fit. Surveys disagree widely on how large that share is, so the number that matters is your own: structured return reason codes will tell you within a month. Next comes expectation mismatch, "it looked different online", which better product data and photography fix, and which ties into how generative AI is changing product discovery. Then bracketing, which you can detect at the moment of the order.
Predicting which orders will come back
This model scores each line item at the moment the order is placed. One change against the first version matters a lot: the split between training and test data is by time, not at random. With a random split, lines from the same order or the same customer land on both sides, and the model looks better on paper than it will be in real life.
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import classification_report, roc_auc_score
import joblib
RETURN_FEATURES = [
'size_risk', 'product_avg_return_rate', 'customer_return_rate', 'customer_order_count',
'order_value', 'order_item_count', 'is_bracketing', 'high_return_product',
'high_return_customer', 'review_sizing_flag', 'days_since_last_order', 'is_sale_item',
]
def add_return_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['is_bracketing'] = df['same_product_multiple_sizes']
df['size_risk'] = df['size_deviation'].abs() # ordered size index minus usual size index
df['high_return_product'] = (df['product_avg_return_rate'] > 0.25).astype(int)
df['high_return_customer'] = (df['customer_return_rate'] > 0.30).astype(int)
df['review_sizing_flag'] = (df['product_review_mentions_sizing_issue'] > 3).astype(int)
return df
def train_return_risk_model(lines_csv: str):
"""
One row per order line item: order_date, size_deviation, product_avg_return_rate,
product_review_mentions_sizing_issue, customer_return_rate, customer_order_count,
order_value, order_item_count, same_product_multiple_sizes, days_since_last_order,
is_sale_item, was_returned (the target: 1 returned, 0 kept)
"""
df = add_return_features(pd.read_csv(lines_csv, parse_dates=['order_date']))
df = df.sort_values('order_date')
cut = int(len(df) * 0.8) # train on the older 80%, test on the newest 20%
train, test = df.iloc[:cut], df.iloc[cut:]
model = GradientBoostingClassifier(n_estimators=200, max_depth=5, learning_rate=0.1, subsample=0.8)
model.fit(train[RETURN_FEATURES], train['was_returned'])
y_proba = model.predict_proba(test[RETURN_FEATURES])[:, 1]
print(f"AUC: {roc_auc_score(test['was_returned'], y_proba):.3f}")
print(classification_report(test['was_returned'], model.predict(test[RETURN_FEATURES])))
joblib.dump(model, 'return_risk_model.pkl')
return model
The product and customer return rates must be calculated only from orders before each line's own order date. If they include later returns, the model is reading the answer from the future, which is the most common way a return model looks brilliant in testing and disappoints afterwards.
I dropped the payment method feature from the first version, which came with an unsourced claim about buy now pay later customers. If you suspect a payment method matters, add it, and let your own data say whether it does.
Use the score to help the customer choose well, never to quietly treat people worse. A size suggestion helps everyone. A hidden penalty for "high return customers" is a different kind of decision, and one I would not automate.
Worked example: one Burren order
A customer orders the Errigal waterproof jacket in M and in L. Her usual size, from four kept orders, is M. The jacket's return rate so far is 31%, and five reviews say it runs small.
is_bracketingis 1 (two sizes of the same product).size_riskis 0 for the M and 1 for the L.high_return_productis 1, because .review_sizing_flagis 1, because .
The model scores both lines above 0.6 (made up scores for the example), so one of the two jackets is very likely coming back. The size recommender in the next section suggests L for her. Before dispatch, Burren sends one plain message: "This jacket runs small. Most customers who usually wear M kept the L." That is the information she needed, and if she drops the M, one parcel never makes the trip back.
Recommending a size from people like the customer
The same idea that powers "customers who bought this also bought" in custom AI recommendations in Solidus can suggest a size: find past customers who are similar to this one and who kept this product, and let them vote.
import numpy as np
import pandas as pd
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler
PROFILE = ['height_cm', 'weight_kg', 'usual_size_tops_index', 'usual_size_bottoms_index']
def build_size_recommender(history_csv: str):
"""
One row per purchase: user_id, product_id, size_ordered, was_returned,
plus the optional profile fields above (sizes as numbers: XS 0, S 1, M 2 and so on).
"""
kept = pd.read_csv(history_csv).query('was_returned == 0')
def recommend(product_id: int, profile: dict, k: int = 20) -> dict:
rows = kept[kept['product_id'] == product_id].dropna(subset=PROFILE)
if len(rows) < 10 or any(profile.get(f) is None for f in PROFILE):
return {'recommendation': None, 'confidence': 'insufficient_data'}
scaler = StandardScaler().fit(rows[PROFILE]) # centimetres would otherwise drown out sizes
X = scaler.transform(rows[PROFILE])
user = scaler.transform(pd.DataFrame([profile])[PROFILE])
nn = NearestNeighbors(n_neighbors=min(k, len(rows))).fit(X)
distances, idx = nn.kneighbors(user)
weights = 1 / (distances[0] + 1e-6)
votes = {}
for size, w in zip(rows.iloc[idx[0]]['size_ordered'], weights):
votes[size] = votes.get(size, 0) + w
best = max(votes, key=votes.get)
share = votes[best] / sum(votes.values())
return {
'recommendation': best,
'confidence': 'high' if share > 0.6 else 'medium' if share > 0.4 else 'low',
'confidence_score': round(share, 3),
}
return recommend
The first version fed raw centimetres, kilograms and size codes straight into the distance calculation. Height in centimetres varies by tens, a size index by one or two, so height decided almost everything. Scikit learn's own documentation warns that many estimators behave badly when features are on very different scales, which is what the StandardScaler line fixes.8 I also removed the fallback that recommended the most popular size when a customer had no profile. That is not a recommendation, it is a guess with a confident face.
Height and weight are personal data, and the GDPR asks that you collect only what is adequate, relevant and limited to what you need.9 So make the fields optional, say why you ask, and use them for nothing else.
Worked example: a size vote with three neighbours
To keep the arithmetic visible, take three nearest neighbours instead of 20. After scaling, their distances to the customer are 0.5 (kept M), 1.0 (kept M) and 0.8 (kept L).
- Weights are one over the distance: , , .
- Votes: M gets , L gets . Total .
- Share for M: , above 0.6, so the confidence is high.
The suggestion is M, with high confidence. The closer a neighbour, the louder their vote, which is the whole idea.
Wiring the return checks into Solidus
The return check runs after the order is placed, so it subscribes to Solidus's own order_finalized event and does its work in a background job.7
module Burren
class ReturnRiskSubscriber
include Omnes::Subscriber
handle :order_finalized, with: :assess
def assess(event)
ReturnRiskJob.perform_later(event.payload[:order].id)
end
end
end
module ReturnPrevention
class Pipeline
def evaluate(order)
lines = order.line_items.includes(variant: { option_values: :option_type })
risks = lines.map do |item|
risk = ScoringService.return_risk(item, timeout: 2) || { probability: nil }
{ line_item: item, risk: risk, size: check_sizing(item, order.user) }
end
{ order: order, lines: risks, bracketing: bracketing?(order) }
end
private
def size_of(variant)
variant.option_values.find { |ov| ov.option_type.name == 'size' }&.name
end
def check_sizing(item, user)
ordered = size_of(item.variant)
return nil unless ordered && user
rec = ScoringService.recommend_size(product_id: item.product.id, user_id: user.id)
return { ordered: ordered, mismatch: false } unless rec && rec[:recommendation]
return { ordered: ordered, mismatch: false } if rec[:recommendation] == ordered || rec[:confidence] == 'low'
{ ordered: ordered, recommended: rec[:recommendation], mismatch: true }
end
def bracketing?(order)
order.line_items.group_by(&:product).any? { |_, items| items.map(&:variant_id).uniq.size > 1 }
end
end
end
Bracketing now means two different variants of the same product, so two of the same jacket in the same size, a gift for a friend, no longer counts.
Finding the products that keep coming back
Scoring single orders is half the job. The other half is fixing the products that cause returns in the first place. Solidus records returns as Spree::ReturnItem records, each tied to an inventory unit and, where the customer gave one, a Spree::ReturnReason.
module ReturnPrevention
class ProductAnalytics
def analyse(product)
returns = Spree::ReturnItem
.joins(:inventory_unit)
.where(spree_inventory_units: { variant_id: product.variant_ids })
.includes(:return_reason, inventory_unit: { variant: { option_values: :option_type } })
.to_a
sold = Spree::LineItem
.joins(:order).merge(Spree::Order.complete)
.where(variant_id: product.variant_ids)
.includes(variant: { option_values: :option_type })
.to_a
units_sold = sold.sum(&:quantity)
{
return_rate: units_sold.positive? ? (returns.size * 100.0 / units_sold).round(1) : 0,
reasons: returns.group_by { |r| r.return_reason&.name || 'none given' }.transform_values(&:size),
by_size: by_size(sold, returns)
}
end
private
def size_of(variant)
variant.option_values.find { |ov| ov.option_type.name == 'size' }&.name
end
def by_size(sold, returns)
units = sold.group_by { |li| size_of(li.variant) }.transform_values { |lis| lis.sum(&:quantity) }
back = returns.group_by { |r| size_of(r.inventory_unit.variant) }.transform_values(&:size)
units.map do |size, n|
rate = (back.fetch(size, 0) * 100.0 / n).round(1)
{ size: size, sold: n, returned: back.fetch(size, 0), rate: rate, investigate: rate > 30 }
end
end
end
end
The first version filtered on orders and inventory_units as table names. Solidus prefixes its tables with spree_, so those queries would have failed. This version uses Spree::Order.complete, the shop's own scope for completed orders, and does the grouping in Ruby, which is easier to read and fast enough for one product.
Worked example: the Errigal jacket by size
Over a season, Burren sells 400 Errigal jackets and 124 come back, a return rate of . By size:
| Size | Sold | Returned | Rate |
|---|---|---|---|
| S | 60 | 24 | 40.0% |
| M | 150 | 51 | 34.0% |
| L | 130 | 32 | 24.6% |
| XL | 60 | 17 | 28.3% |
S and M are above 30%, and the return reasons say "too small" three times as often as "too large". The fix is not a model. It is one line on the product page, "runs small, consider a size up", and a corrected size chart. The model found it, a person fixes it.
What prevention is worth
The first version of this article had a business case with two arithmetic errors in it. It counted recovered carts and then counted them again at a 20% conversion rate, and it multiplied the money value of returns by a per parcel cost as if euro were parcels. Here it is again, done properly, on Burren.
Worked example: fewer abandoned carts
The example's assumptions: 10,000 carts a month, a 70% abandonment rate, 3,000 orders, an average order of €70, so €210,000 in monthly revenue, and a gross margin of 40%.
If the on page interventions bring the rate down by two points, from 70% to 68%:
- Completed orders: , so 200 more than before.
- Extra revenue: a month.
- Extra margin: a month.
That assumes the recovered carts are as large as the average order, which you should check, because the shoppers you win back tend to be the ones closest to buying, and they may not be typical.
Worked example: fewer returns
The example's assumptions: of the 3,000 orders, 25% are returned, so 750 returns a month, and each return costs Burren €12 in postage, handling and inspection.
- Processing cost today: a month.
If size suggestions and better product pages bring the rate down three points, from 25% to 22%:
- Returns: , so 90 fewer.
- Processing saved: a month.
- Revenue no longer refunded: , worth in margin.
Together about €3,600 a month, and that is before the stock that comes back damaged or out of season and has to be sold off at a discount. Smaller numbers than the first version promised, and real ones, which is grand. The models do not need to be perfect. They need to be better than doing nothing, and cost less than they save.
Where to start
- Instrument the checkout on the server. Record step times, payment switches, item removals, promo attempts and time on the delivery step. These are your features.
- Give returns structured reason codes in Solidus, never free text only. "Too small", "too large", "not as pictured", "changed my mind".
- Train the abandonment model first. The feedback loop is days, not weeks, and the on page interventions need no email permission.
- Add size suggestions once you have a few hundred kept orders per product family with profile data, and check the vote on a handful of real orders by hand before it goes live.
- Close the loop. Every session and every order feeds the next training run, and retraining monthly keeps the models in step with the season.
Footnotes
-
Baymard Institute, Cart abandonment rate: 50 statistics and why people abandon, updated 28 October 2026, https://baymard.com/lists/cart-abandonment-rate
-
National Retail Federation, NRF and Happy Returns report: 2024 retail returns to total $890 billion, press release, 5 December 2024, https://nrf.com/media-center/press-releases/nrf-and-happy-returns-report-2024-retail-returns-total-890-billion
-
scikit learn, GradientBoostingClassifier, API reference, accessed November 2026, https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingClassifier.html
-
scikit learn, precision_recall_curve, API reference, accessed November 2026, https://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html
-
European Communities (Electronic Communications Networks and Services) (Privacy and Electronic Communications) Regulations 2011, S.I. No. 336 of 2011, regulation 13(1) and (11), Irish Statute Book, 2011, https://www.irishstatutebook.ie/eli/2011/si/336/made/en/print
-
Data Protection Commission, Direct Marketing, case study on an airline feedback email, dataprotection.ie, https://www.dataprotection.ie/en/dpc-guidance/case-studies/electronic-direct-marketing/direct-marketing
-
Solidus Guides, Subscribing to events, accessed November 2026, https://guides.solidus.io/customization/subscribing-to-events
-
scikit learn, Preprocessing data: standardization, or mean removal and variance scaling, user guide, accessed November 2026, https://scikit-learn.org/stable/modules/preprocessing.html
-
Regulation (EU) 2016/679 (General Data Protection Regulation), Article 5(1)(c), data minimisation, EUR-Lex, https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679