Founder & Lead Engineer, RAITHub
A returns/RMA system loses money in the refund maths, not the return form. The four leaks: refunding tax you do not reclaim, refunding outbound shipping by accident, skipping a restocking fee, and a store-credit balance that drifts. Fix them by computing the refund from the order lines, not a typed-in total, and recording store credit as an append-only ledger.
If you would rather have it built for you, see how RAITHub would build this below.
This post is about the money. The return lifecycle itself, the RMA states and when to buy a tool such as Loop or ReturnGO, is covered in returns management software, built or bought. Here we assume you have a returns flow and the refunds keep coming out slightly wrong.
Why does a returns system lose money even when it works?
Because the form works and the arithmetic does not. A shopper returns one of three items, a staff member types a refund amount into the gateway, and nobody checks it against the order. Over a few thousand returns, small errors in one direction add up: too much refunded is a direct loss, too little is a chargeback and a complaint. The fix is to make the refund a computed number the system can defend, line by line, and to never let a human type the total.
| Leak | What goes wrong | The correct rule |
|---|---|---|
| Tax | Tax on returned goods is refunded to the shopper but never reclaimed, or not refunded when it should be | Reverse the tax that was charged on the returned lines, at the rate on the original order, not today's rate |
| Outbound shipping | The whole order refund includes the original delivery charge on a partial return | Refund shipping only on a full return, or per your stated policy; never by default on a partial |
| Restocking fee | A policy allows a fee for opened or late returns, but staff forget to apply it | Apply the fee as a line in the refund calculation, driven by reason code and condition |
| Store credit | Credit is issued, spent and expired in separate places, so the balance drifts | One append-only ledger; the balance is the sum of its rows, never a stored number you edit |
How should the refund amount be calculated?
From the original order lines, scoped to what is coming back. The shopper returning two of five units gets back what those two units were actually charged, including their share of any order-level discount, plus their tax, minus any fee. Never refund a proportion of the order total: an order-level coupon or a mixed-tax basket makes that wrong.
Work from the captured values stored on each order line at the time of sale: unit price, the discount allocated to that line, and the tax charged on it. A returns engine that recomputes prices today will disagree with what the shopper paid the moment a price or tax rate changes.
-- Refund base for the returned units of one order line.
-- Everything comes from values captured at sale time on the line.
SELECT
rl.order_line_id,
rl.qty_returned,
ol.unit_price_ex_tax,
ol.discount_per_unit, -- line's share of order discounts
ol.tax_rate, -- the rate charged on the original order
rl.qty_returned * (ol.unit_price_ex_tax - ol.discount_per_unit) AS goods_ex_tax,
ROUND(rl.qty_returned * (ol.unit_price_ex_tax - ol.discount_per_unit)
* ol.tax_rate, 2) AS tax_refund
FROM return_lines rl
JOIN order_lines ol ON ol.id = rl.order_line_id
WHERE rl.return_id = $1;
The refundable total is the sum of goods_ex_tax and tax_refund across the returned lines, plus original shipping only when the policy says so, minus any restocking fee. That number is what the system sends to the gateway, and it is the number you can show the shopper and your accountant.
When should original shipping and restocking fees change the refund?
Write the policy as data, not as a judgement call at the counter. Two rules cover most stores:
- Outbound shipping: refund it when every line on the order is returned and the return is the store's fault (wrong or faulty item). Do not refund it on a partial return, and do not refund it when the shopper simply changed their mind, unless your stated policy says otherwise.
- Restocking fee: a percentage or flat fee triggered by reason code and inspected condition, for example opened, used or returned after the free window. Store the fee rule per category, because a sealed cosmetic and a bulky appliance are not the same risk.
Both belong in the calculation, driven by fields on the return, so the same inputs always produce the same refund. That is also what makes the number testable.
How do you model store credit so the balance never drifts?
As an append-only ledger. Every event that changes a shopper's credit, issuing it from a return, spending it on an order, expiring it, or an adjustment, is one immutable row with a signed amount. The balance is the sum of the rows. You never store a balance you edit, because the moment two requests edit it at once, or one path forgets to, the number is wrong and you cannot tell when it went wrong.
CREATE TABLE store_credit_entries (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
amount numeric(12,2) NOT NULL, -- signed: +issue, -spend, -expiry
reason text NOT NULL CHECK (reason IN ('return','spend','expiry','adjustment')),
return_id bigint REFERENCES returns(id),
order_id bigint REFERENCES orders(id),
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX store_credit_by_customer ON store_credit_entries (customer_id, created_at);
-- Spendable balance: never a stored column, always the sum of the rows.
-- SELECT COALESCE(SUM(amount), 0) FROM store_credit_entries WHERE customer_id = $1;
Spending credit at checkout inserts a negative row inside the order transaction, after checking the balance with a row lock, so two tabs cannot both spend the last of it. This is the same discipline as stopping overselling: the check and the write happen together, covered in preventing overselling with a reservation inside the order transaction.
Why must refunds and credit be idempotent?
Because the button gets double-clicked and the webhook gets retried. If "issue refund" runs twice, you pay twice. Give every refund a stable key, for example the return ID plus an attempt number, pass it to the gateway as its idempotency key, and record the gateway's refund ID before you mark the return refunded. On a retry you look up that record and return the same result instead of charging again. The same reasoning for payment events is in testing payments and webhooks.
How do you test that refunds come out exact?
The calculation is a pure function of the return and its order, so unit-test it hard, then cover the money path end to end. Table-driven cases catch the leaks above:
import { describe, it, expect } from 'vitest'
import { calcRefund } from './refund'
describe('refund calculation', () => {
it('refunds captured tax at the original rate, not today rate', () => {
const r = calcRefund({
lines: [{ qty: 1, unitExTax: 100, discountPerUnit: 0, taxRate: 0.2 }],
fullReturn: false, reason: 'changed_mind', restockPct: 0,
})
expect(r.goodsExTax).toBe(100)
expect(r.taxRefund).toBe(20)
expect(r.shippingRefund).toBe(0) // partial, change of mind
})
it('does not refund outbound shipping on a partial return', () => {
const r = calcRefund({
lines: [{ qty: 1, unitExTax: 50, discountPerUnit: 0, taxRate: 0 }],
fullReturn: false, originalShipping: 9.99, reason: 'changed_mind', restockPct: 0,
})
expect(r.shippingRefund).toBe(0)
})
it('applies a restocking fee to the goods value', () => {
const r = calcRefund({
lines: [{ qty: 1, unitExTax: 200, discountPerUnit: 0, taxRate: 0 }],
fullReturn: true, reason: 'changed_mind', restockPct: 0.15,
})
expect(r.restockingFee).toBe(30)
expect(r.total).toBe(170)
})
})
End to end, a return of two items from a five-item order should pay back exactly those two lines, and a second click of "refund" should not pay a second time. A store-credit test should issue, spend and expire, then assert the balance equals the sum of the ledger rows.
Buy, build or hire?
| Option | Choose this when | The catch |
|---|---|---|
| Off-the-shelf returns app (Loop, ReturnGO, AfterShip Returns) | A standard Shopify store with card refunds and simple tax | Tax reversal, restocking rules and store credit follow the app's model; local gateways and cash on delivery often fall outside it |
| Spreadsheet plus manual gateway refunds | A handful of returns a week and one trusted person doing them | The refund total is typed by hand, so the leaks in this post are invisible until you reconcile |
| Custom build | Refunds run through local gateways or cash on delivery, tax reversal matters, or store credit is a real currency in your store | You own the ledger and the tests that keep it exact |
How long does it take to build yourself, and what is the risk?
For an experienced backend developer adding correct refund maths and a store-credit ledger to an existing store, our estimate is 2 to 4 weeks, including idempotent refunds and the test suite. The main risk is historical data: orders that never captured per-line tax and discount cannot be refunded correctly after the fact, so decide a fallback rule for old orders before you switch the new engine on. Tax treatment of refunds varies by jurisdiction; this is general information, so confirm the rules that apply with your tax adviser.
Why RAITHub for this
- Ledgers in production. Sundor Skin, a B2B wholesale platform RAITHub built, runs credit and adjustments as append-only entries with row-level security, across 146 PostgreSQL tables and 530+ tests. See the Sundor Skin case study.
- Money paths are tested. Refunds, credit spend and the reconciliation report get unit and integration tests gated in CI, so a change to the policy cannot quietly change the maths.
- Local payments. TheSkinProof, the founder's own venture built and run by RAITHub, handles bKash, Nagad, SSLCommerz and cash on delivery, where off-the-shelf returns apps often stop.
When you don't need us
- A returns app already fits. Standard card refunds, simple tax, no store credit: configure the tool.
- Volume is tiny. A careful person and a checklist beat a build for a few returns a week.
- You only need the calculation. The refund function above is a fair start for your own developer.
How RAITHub would build this
- Refund engine: line-level refunds from captured sale values, tax reversal at the original rate, shipping and restocking rules as policy data.
- Store-credit ledger: append-only entries, balance by sum, spend inside the order transaction with a row lock and expiry.
- Idempotent refunds: stable keys to the gateway, recorded refund IDs, safe retries.
- Reconciliation: a report that ties refunds and credit back to orders and the gateway, with tests gated in CI.
Timeline: adding this to an existing store is backend work, typically 6 to 12 weeks; inside a new store MVP it fits the 4 to 6 week fixed-scope range. See API and backend development, SaaS development and the ecommerce industry page.
You receive: automated tests and CI covering the refund maths and the credit ledger, handover docs, and full IP in your name under NDA.
Next step: book the free 15-minute technical audit with a sample order and your returns policy, and we will follow up with a written fixed quote.
Frequently asked questions
Why is my returns system refunding too much?
Usually because the refund is a typed total or a proportion of the order, not computed from the returned lines. Refund each returned unit at what it was actually charged, including its share of discounts and its tax, and the total stops drifting.
Should I refund the original shipping on a return?
On a full return where the store was at fault, usually yes. On a partial return, or a change of mind, usually no. Write the rule as policy data so it is applied the same way every time, and state it to shoppers.
How should store credit be stored?
As an append-only ledger of signed entries: issue, spend, expiry and adjustment. The balance is the sum of the rows. Never keep an editable balance column, because it drifts the moment two paths change it at once.
How do I stop a refund being paid twice?
Make it idempotent. Give each refund a stable key, pass it to the gateway as the idempotency key, and record the gateway refund ID before marking the return refunded. A retry then returns the same result instead of refunding again.
Does the tax on a refund reverse automatically?
Not unless you build it to. Reverse the tax that was charged on the returned lines at the rate on the original order. Treatment varies by jurisdiction, so confirm the rules with your tax adviser; this is general information.
Can I use a returns app instead of building one?
Often yes, for a standard card-refund store. Build custom when refunds run through local gateways or cash on delivery, when tax reversal and restocking rules are specific, or when store credit is a real currency in your store.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.