Skip to main content
Back to AI Commerce Lab
Operations·August 2026·9 min read

Returns and Refunds Are an Engineering Problem

Most returns operations run on a policy PDF, a spreadsheet of exceptions, and an agent making a judgment call at 4pm on a Friday. The policy is real. It is simply not executable, so every edge case becomes a person.

Returns get treated as a customer-service cost line and staffed accordingly. The engineering view is different and more useful: a return is a long-running distributed transaction that moves goods one way, money the other way, and inventory state in a third direction, across systems that do not share a database.

Framed that way, the failure modes stop being mysterious. Duplicate refunds, goods that arrive and are never restocked, and customers who insist a refund never landed are all consequences of modeling a return as an event rather than as a state machine.

The 3 layers a returns decision passes through

Every return decision is really 3 decisions, and mixing them is the root of most bad returns architecture.

  1. Eligibility. Is this return permitted? Window, item condition category, final-sale flag, hazardous-goods class, jurisdiction. Deterministic rules only, with no model anywhere near it.
  2. Disposition. Where does the item physically go? Restock, refurbish, liquidate, donate, or destroy in field. Cost-driven and partly predictive, since shipping a 12 dollar item back can cost more than writing it off.
  3. Settlement. How does money move, in what order, and how do we guarantee it happens exactly once? The only layer that has to be idempotent.

Keep them as separate services or separate modules with separate tests. Teams that merge eligibility into settlement end up unable to answer a simple audit question: was this refund allowed, or did it just succeed?

Policy as code, and what that phrase should mean

Policy as code does not mean moving your return rules into a config file. It means the decision is a pure function whose inputs are data and whose output is a decision plus a reason, with no side effects and no database writes.

Open Policy Agent and its Rego policy language is the clearest example of the shape. The application sends structured input, the engine evaluates policy written in Rego, and a decision comes back. The documentation frames it as decoupling policy decision-making from policy enforcement, which is exactly the split returns systems need.

You do not have to adopt OPA. You do have to adopt the property: a returns decision that can be replayed offline against historical inputs, producing an identical result, is a decision you can audit and test.

The test suite that follows is cheap and boring. Feed 200 historical return cases through the engine, assert the outcome and the reason code, and any policy change that silently reclassifies past cases fails the build.

The legal floor is not negotiable and it varies

Policy engines get built with the merchandising rules and none of the statutory ones, which is how a configurable system ends up with a hardcoded 30-day constant.

In the United States, 16 CFR 435.2 requires a seller to have a reasonable basis to expect shipment within the time stated in the solicitation, or within 30 days of a properly completed order where no time is stated, and to make a prompt refund when it cannot meet that and the buyer does not consent to the delay.

Note what the rule does not say. It says prompt, without a day count, which means the refund SLA is a policy input per jurisdiction rather than a constant, and your engine needs a field for it from day 1.

Classification is a routing problem, not a chat problem

The most common mistake in AI-assisted returns is asking a model to write the customer reply. The higher-value job is upstream: read the unstructured message, emit a small structured object, and hand it to the deterministic engine.

A good classifier output has 5 fields. The reason code from a closed enum, the item identifiers referenced, the outcome the customer asked for, a confidence score, and the evidence spans from the message that support the classification.

Evidence spans matter more than they look. They give the human reviewer something to check in 3 seconds instead of re-reading the whole thread, and they make disagreements between reviewer and model diagnosable.

The model never decides eligibility. It extracts facts; the policy engine applies rules. That boundary is what keeps a returns system explainable when a regulator or a finance controller asks why a specific refund was approved.

Model returns as a first-class object, not a refund with a note

If your order management system represents a return as a refund record with a comment field, you cannot answer where the goods are, and you will discover this during your first restock reconciliation.

Look at how the Shopify Admin API models it. The returnCreate mutation produces a Return in an open state and a reverse fulfillment order alongside it, so the goods movement is a tracked entity separate from the money movement.

Whatever platform you run, copy that separation. Return, return line items, reverse fulfillment order, and refund are 4 distinct objects with 4 distinct lifecycles, and collapsing them is a decision you pay for at quarter close.

Refunds are a distributed transaction, so treat them like one

A refund is not a boolean. Stripe's refund object moves through pending, succeeded, failed, canceled and requires_action, with failure reasons including insufficient funds, an expired or canceled card, and a charge that was disputed while the refund was pending.

Any system that models refunds as issued or not-issued will generate a support ticket for every refund that sits in pending. Build the 5-state machine, expose the state in the customer-facing order view, and most of the "where is my refund" volume disappears.

Then there is the reversal case, which is the single most misunderstood thing in retail payments. Refunds issued shortly after the original charge come back as a reversal, so the original charge drops off the statement and no separate credit ever appears, and the customer reasonably reports that nothing was refunded.

Stripe also notes customers typically see the credit around 5 to 10 business days later depending on the bank. Put that range in the confirmation message. It is the cheapest ticket deflection available to a returns team.

Exactly-once, or how not to refund twice

Every write in the settlement layer needs an idempotency key derived from the return identifier and the operation, never from a timestamp or a random value generated per retry.

Stripe's idempotency contract is worth reading closely: the first result for a key is saved and replayed on subsequent requests including 500 responses, keys can be pruned after 24 hours, and reusing a key with different parameters is an error rather than a silent overwrite.

Two design consequences follow. Your key must outlive the retry window you actually use, and your parameters must be deterministic, which rules out including a recalculated tax figure that might round differently on a retry.

# settlement: one key per (return, operation), stable across every retry
def refund_key(return_id: str, op: str, attempt_group: str) -> str:
    return f"ret:{return_id}:{op}:{attempt_group}"
# attempt_group changes only when a human deliberately re-authorizes,
# never on an automatic retry. A new key means a new money movement.

The escalation path

SignalRouteWhy
Classifier confidence under thresholdHuman queueUncertainty is a routing signal, not a failure
Refund value over a per-tier ceilingSupervisor approvalMoney movement wants a second party
3rd return in 60 days on one accountFraud reviewA pattern, not an incident
Hazardous or battery-containing itemLogistics specialistCarrier and disposal rules are legal, not policy
Message mentions injury, safety or recallImmediate human, no automated replyDuty of care outranks handle time
Dispute already open on the chargeBlock the refundRefunding a disputed charge risks paying twice

The last row is not hypothetical. Stripe publishes a specific failure reason for a charge disputed while a refund is pending, which exists because this happens often enough to need a code.

What to automate, what stays human

DecisionAutomateReasoning
Eligibility window and condition checkYesDeterministic, auditable, replayable
Reason-code extraction from free textYes, behind a confidence gateHigh volume, reversible, sampled for review
Label generation and reverse logistics bookingYesReversible and cheap to redo
In-policy refund under a value ceilingYesBounded blast radius, fully logged
Goodwill credit outside policyNoPrecedent-setting and hard to unwind
Safety, injury and recall handlingNoLegal exposure and duty of care
Fraud determinationModel recommends, human decidesCost of a false positive lands on a real customer
Policy change itselfNoReviewed, versioned and deployed like code

The OWASP Top 10 for LLM Applications lists excessive agency as a named risk, and an automated refund with no value ceiling is the textbook version of it. The ceiling is the control, not the model's accuracy.

What the numbers looked like

On one L1 returns-triage build, handle time fell 41%.

Handle time was the headline and the least interesting figure. The escalation drop is what says the classifier was routing correctly, rather than agents closing tickets faster.

If you only instrument one thing, instrument escalation rate by reason code. It tells you which policy is ambiguous, which is a question the policy owner can act on and a handle-time chart never answers.

FAQ

Do we need a policy engine, or can rules live in the order management system?

They can live in the OMS if you can replay a historical case and get an identical decision. If you cannot, the rules are entangled with state and you have no audit story.

How do we stop the classifier approving something it should not?

The classifier does not approve anything. It emits facts, and a deterministic engine decides, which means an incorrect classification produces a wrong reason code rather than a wrong payout.

What is the right confidence threshold?

Pick it from the escalation capacity you have, then tune. Start high enough that roughly 30% of cases route to a human, and lower it only after per-reason-code accuracy on the reviewed sample holds steady.

Should refunds be issued before the item is received?

For low-value items where return shipping exceeds recovery value, yes, with a per-customer annual cap. That is a finance decision expressed as 2 policy inputs, not an engineering one.

How do we handle a policy change retroactively?

Version the policy and stamp every decision with the version that produced it. Retroactive application is then an explicit backfill job you can scope, rather than an argument about what the rules were in March.

Why does a customer say the refund never arrived?

Usually a reversal rather than a credit, where the original charge simply disappears from the statement. Surfacing the refund state and a reference number in the order view resolves most of these before a ticket is opened.

References

From the Destm engineering archive. For current work on this topic, start at Solutions or the blog.