Product engineering
POS partial refunds: who gets the last cent?
A customer brings back the mug from a USD 30 basket. They paid USD 20 after a USD 10 discount. What do you give them back? If checkout saved only the basket total, the returns screen has a missing answer. We'd settle that while recording the sale, so the person at the counter doesn't have to invent a refund policy.

The receipt needs an answer for each item
Our fictional basket has a mug, a notebook and a tote, all USD 10 each. One USD 10 discount applies across the basket. We assume a fully paid sale, one unit per line, and a return policy that refunds each item's original discounted amount. Tax, shipping and fees are outside this example.
Split the discount equally and each item gets USD 3.333... off. Round every discount to USD 3.33 and you've only given USD 9.99 off. Round every paid amount to USD 6.67 and three refunds add up to USD 20.01. We need to place that last cent somewhere.
We'll give it to line A. The mug's saved paid amount is USD 6.66; the other two are USD 6.67 each. Sorting the returns screen differently shouldn't move that cent to another item.
| Receipt line | Original price | Allocated discount | Saved paid amount |
|---|---|---|---|
| A: mug | USD 10.00 | USD 3.34 | USD 6.66 |
| B: notebook | USD 10.00 | USD 3.33 | USD 6.67 |
| C: tote | USD 10.00 | USD 3.33 | USD 6.67 |
| Total | USD 30.00 | USD 10.00 | USD 20.00 |
Work in cents, then assign the remainder
For USD, store 10.00 as 1000 integer cents alongside the currency. Stripe's API also expects amounts in the currency's minor unit, but that doesn't mean multiplying every currency by 100: its JPY example uses whole yen. Check the currency and provider rules at the boundary.
Our allocation starts with eligible lines only. For each one, multiply the basket discount by that line's eligible amount, then divide by the eligible subtotal. Keep the whole cents and the remainder separately. With our equal prices, that gives 333 cents per line and leaves one cent unassigned.
Give the leftover cents to the largest fractional remainders first. When remainders tie, use a stable receipt-line ID; A comes before B and C here. Save the resulting allocation with the sale. This is the policy we chose for the example, not a claim about how every POS or payment provider rounds.
Validate the inputs before calculating: integer minor units, positive eligible amounts, unique line IDs and a discount between zero and the eligible subtotal. Use arithmetic that can hold the intermediate products exactly. Python integers handled our local test; a JavaScript implementation would need safe-integer bounds or an appropriate exact numeric type.
Tomorrow's price doesn't change yesterday's mug
Suppose the mug now costs USD 12, or the promotion has ended. The return still starts from its saved receipt allocation. In our example, returning the mug refunds USD 6.66. Returning the tote next refunds USD 6.67, leaving USD 6.67 for the notebook.
Keep the original line price, quantity, discount allocation, currency and paid amount together. Shopify separates the discount's rule from its calculated DiscountAllocation amount. Saving the promotion's name alone would still leave us looking for the amount the customer paid.
A line with several units needs one more decision. If its paid total won't divide evenly, record how the cents belong to individual units, or use a defined cumulative refund rule. Dividing and rounding afresh on every return can spend the same cent twice. Our test uses one unit per line; it doesn't implement either multi-unit policy.
Calculating a refund doesn't send it
The server needs to check the operator's permission and load the saved sale, including earlier returns. We'd reserve a pending refund against the remaining balance in one atomic database operation, so two tills can't both spend the same available amount. That reservation needs to cover the item as well as the payment.
Stripe permits repeated partial refunds up to the remaining unrefunded charge amount. That's a payment-level limit. Your POS still needs its own item-level checks so the same mug doesn't get returned twice while the overall payment has money left.
Keep requested, pending, failed and confirmed refunds distinct, with the provider reference attached. When a response is uncertain, reconcile it before releasing the reservation or trying another refund. Returning an item to stock is a separate decision too: a broken mug can qualify for a refund without going back on the shelf.
What our small test actually checked
We tried all six return orders for this basket with the integer calculation above. Each returned USD 20.00 in total. Changing the input line order kept the same allocation. The in-memory check also rejected a second return of the same item.
We also checked 7,424 small allocation cases, covering line values from one to eight minor units and every discount from zero to the subtotal. Each allocation summed to the requested discount and stayed within its line amount. Seven invalid-input cases were rejected. These are arithmetic checks, not a live payment, tax, database or concurrency test.
For a real build, we'd agree the return policy with the business before extending the calculation. Threshold promotions, tax adjustments, split payments and exchanges can change what needs recording. A rule that claws back a promotion after a return would need its own explicit adjustment; it isn't the refund policy used here.
Try the sales side, then bring us the awkward return
Rubix's Ez-POS work connected sales, inventory, customer records and orders. Our public demo lets you make a discounted sale, inspect its receipt and settle a customer account. It uses fictional data and doesn't process refunds or payments. The refund calculation here is a separate teaching example.
If you're planning a POS with Rubix, bring a sample sale and explain what should happen when part of it comes back. We can help work through the receipt, operator controls and accounting handoff before building the screens. A made-up basket is enough to start that conversation; keep real customer and payment details private.
