When a customer returns a $50 item with a $5 restocking fee, who actually pays the $5?
In most returns systems, the answer is: the retailer does. The customer gets $45 back to their original card, and the $5 the retailer was supposed to recover never lands anywhere — it's just absorbed into the refund. Multiply that across thousands of returns a month and the leak adds up to real money.
This is the story of how we fixed that at PostCo.
Context
PostCo is a returns management platform that sits between Shopify retailers and their customers. When a customer returns an item, we orchestrate the whole flow: validating the return, calculating refund amounts, picking the right refund method, and posting the result back to Shopify.
For a long time, "fees" on returns were modeled as Return Amount Adjustments (RAA) — outcomes attached to PolicyRules, evaluated at return creation time, hardcoded to deduct from the original payment method. Retailers could configure them, but the system was rigid:
- Adjustments were tied 1:1 with
PolicyRuleoutcomes. - The deduction always came out of the refund the customer received in cash.
- There was no way to apply a flat fee unconditionally, or scope a fee to "exchanges only," or split a fee across multiple refund methods.
- Manual ad-hoc adjustments in the resolve modal didn't exist — every adjustment had to round-trip through the policy engine.
The Problem
Two business problems converged.
Retailer cash leakage. When a $50 return had a $5 restocking fee, the customer still got $45 refunded to their original card. The retailer never recovered the cash that went out the door. If instead the $5 came out of an existing gift card balance, the retailer kept the entire $50 in their ecosystem.
Configuration ergonomics. Retailers wanted a Fees page where they could say "all bag returns are charged 15%" or "all returns ship at $5" without learning the PolicyRule UI. They also wanted ops staff to slap an ad-hoc credit or charge onto a return at resolve time without reopening configuration.
The Solution
A new Return Amount Adjustment Preset system that:
- Lives alongside RAA during rollout, gated by a
return_fees_enabledshop flag. - Supports unconditional ("apply to all") or conditional (scoped to a
PolicyRule) presets. - Distributes the fee across return methods in a priority order, only falling through to the original payment method (OPM) as a last resort.
- Exposes a manual adjustment API for the resolve modal — POST/DELETE, sign derived server-side.
- Provides a one-click migration from legacy RAA rules to presets.
Implementation
Data model
Two tables.
return_amount_adjustment_presets:
-
shop_id,title,amount_cents,unit("percentage"or"amount") -
apply_to_all(bool),policy_rule_id(nullable FK) -
applies_to("refund"or"exchange")
return_amount_adjustment_priorities:
- one row per shop, JSONB array of return methods in priority order
The clever bit is the relationship with PolicyRule. Rather than build a parallel rules engine, we extended the existing one:
PRESET_OUTCOME_TYPES = ["Return Amount Adjustment Preset"]
VALID_OUTCOME_TYPES = [...existing, *PRESET_OUTCOME_TYPES]
validates_presence_of :outcome_values,
unless: -> { outcome_type == "Return Amount Adjustment Preset" }
has_one :return_amount_adjustment_preset, foreign_key: :policy_rule_id
The outcome_values skip is the trick — preset rules store their data on the associated record, not in the rule's outcome_values JSON. That keeps the preset editable from the Fees UI without round-tripping through the policy engine.
Distribution priority
The core service, EvaluatePresetAdjustmentsService, builds an "adjustment pool" from all matching presets and distributes it across the customer's enabled return methods in this hardcoded order:
gift_card → discount_code → shopify_store_credit → original_payment_method
Why hardcoded? Because the PM wanted to validate the E2E flow before exposing the priority order as a configurable knob. Shipping the engine first and the configuration UI later was the lower-risk path. The priority table exists in the schema; the admin UI for it is deferred.
There's one safety guard worth calling out: orders sourced from Facebook can't receive adjustments routed to the original payment method, because that source can't honor a cash-back scenario.
adjustments.reject! { |a| a.return_method == :opm } if order.from_facebook?
Manual adjustments
The resolve modal needed a way to attach ad-hoc credits/charges without going through the preset engine. Two decisions worth highlighting.
Server-derived sign. The API takes type: "credit" | "charge" and amount_cents (positive integer). The sign is determined on the server based on type. Floats never enter the picture, and a malicious or buggy client can't flip a charge into a credit.
Manual flag to survive rebuilds. The UpdateService rebuilds preset-derived adjustments every time a return order changes. To prevent that rebuild from blowing away manual rows:
def existing_refund_adjustments
return_order.return_amount_adjustments.where(manual: false)
end
Manual rows are invisible to the rebuild. Only preset/policy rows churn.
Exchange-side charges
Exchanges introduced a wrinkle: lower-value exchanges can produce a negative refund balance.
Customer exchanges a $50 item for a $30 item with a $5 fee → retailer owes them -$25 + needs to charge $5 = customer pays $5 via Stripe.
Two new attributes on ExchangeResource made the resolve modal math correct:
attribute :bonus_credit_cents # extra credit awarded for the exchange
attribute :price_difference_cents # delta between returned and incoming items
These get folded into the resolve modal's charge calculation so the displayed total matches what Stripe will actually charge.
Concurrent migration safety
The migration from legacy RAA to presets has a nasty race: if a return order is created mid-migration, it might evaluate against a return_fees_enabled shop with no presets yet — or worse, with both old RAA rules and new presets active.
The fix in CreateService and UpdateService:
shop.reload
if shop.return_fees_enabled?
evaluate_presets
else
evaluate_raa
end
A reload looks ugly. It is. But the cost of a stale association during a 30-second migration window is wrong fees on real returns, which is unrecoverable. The reload is cheap insurance.
Preview endpoint
The resolve modal needed live fee preview as ops staff toggle which items to reject. New endpoint:
POST /return_orders/:id/return_amount_adjustments/preview
{ scoped_item_ids: [...] }
The service evaluates presets against only the non-rejected items, returning unsaved adjustment instances for display. filter_merchant_deleted_adjustments removes adjustments tied to presets the merchant deleted after the return was created — important for historical accuracy.
UI choices worth calling out
A few small decisions that punched above their weight:
-
applies_tois a radio with two options ("refund"or"exchange"), not a "both" checkbox. The original spec had "Both" as a third option; we removed it because the two flows have different sign semantics (refunds deduct, exchanges charge) and conflating them confused early testers. - Tags are case-insensitive, even though Shopify treats them case-sensitively in some contexts. We lowercase both sides at compare time. Retailers tag inconsistently, and a strict comparison would silently miss matches.
-
Tag input saves on blur, not on every keystroke. Earlier behavior wiped the field when focus moved — a regression caught in QA. Now
onBlurcommits,onChangeupdates local state. - Delete preset surfaces a confirmation modal. Deletes are destructive (the preset is FK'd from historical adjustments via the policy rule), so we confirm before executing.
- Resolve modal hides the manual adjustment UI for legacy RAA orders. Mixing manual rows into a return that was created under the old engine produces inconsistent totals. Easier to lock those returns to the legacy flow and only enable manual adjustments on preset-era returns.
- Validation error UX uses fixed-height field containers so error messages don't shift modal layout when they appear/disappear. Tiny detail, big quality-of-life difference.
What's next
- Configurable priority order (currently hardcoded).
- Stripe charge for negative refund balances on the refund flow (the exchange flow already supports it).
- Trend dashboards: how much retailers retain in store credit vs refund out as cash, which is the actual business metric we're trying to move.
The migration is rolling out cohort by cohort. Real numbers in a month or two.
Thanks for reading. If you've shipped something on a cash path before, I'd love to hear how you handled the migration race conditions — that part felt the most "anything could go wrong" of the whole project.
Top comments (0)