DEV Community

Cover image for Building a Conditional Fee Engine for Returns: How We Stopped Refunding Cash
hans
hans

Posted on

Building a Conditional Fee Engine for Returns: How We Stopped Refunding Cash

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 PolicyRule outcomes.
  • 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:

  1. Lives alongside RAA during rollout, gated by a return_fees_enabled shop flag.
  2. Supports unconditional ("apply to all") or conditional (scoped to a PolicyRule) presets.
  3. Distributes the fee across return methods in a priority order, only falling through to the original payment method (OPM) as a last resort.
  4. Exposes a manual adjustment API for the resolve modal — POST/DELETE, sign derived server-side.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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_carddiscount_codeshopify_store_creditoriginal_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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: [...] }
Enter fullscreen mode Exit fullscreen mode

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_to is 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 onBlur commits, onChange updates 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)