retail.refund-calculate
Refund for returned items, sharing basket discounts back across the sale so every refund is exact to the penny.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript, Python and Rust.
What it does
Works out what to refund when a customer brings items back, so that the refund matches what they actually paid for those items, including their share of any discount on the whole basket, to the penny.
## Two steps
For example
calculateRefund(lines ×3, £5.00, returns ×1)→ lines ×1, total £4.29 one of three teas, with its share of a 5.00 couponcalculateRefund(lines ×3, £5.00, returns ×1)→ lines ×1, total £6.86 a single mug refunds its whole paid pricecalculateRefund(lines ×3, £5.00, returns ×1)→ lines ×1, total £5.14 one of two books; the coupon's spare penny went to the book line
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function calculateRefund(lines: readonly SaleLine[], basketDiscount: Money, returns: readonly ReturnLine[]): Refund
| lines | SaleLine[] | the original sale, every line, whether returned or not |
| basketDiscount | Money | discounts taken off the whole basket (a coupon, a staff discount); 0 for none |
| returns | ReturnLine[] | what is coming back now |
| returns | Refund |
The types it declares, generated into your project
/** One line of the original sale. */
export interface SaleLine {
readonly sku: string;
/** units bought, 1 or more */
readonly quantity: number;
/** what the line cost after its own promotions, before basket discounts */
readonly net: Money;
/** units of this line already refunded by earlier returns */
readonly returnedBefore: number;
}
/** Units of one sale line being returned. */
export interface ReturnLine {
/** 0-based index into lines */
readonly line: number;
/** 1 or more */
readonly quantity: number;
}
/** The refund for one returned line. */
export interface RefundLine {
readonly line: number;
readonly sku: string;
readonly quantity: number;
/** what the whole sale line cost after its share of the basket discount */
readonly paid: Money;
/** the refund for the units returned now */
readonly amount: Money;
}
export interface Refund {
/** in the order the returns were given */
readonly lines: readonly RefundLine[];
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { calculateRefund } from "#fune/retail.refund-calculate@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { allocate } from "./money_allocate.ts"; ← from money.allocate ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { type SaleLine, type ReturnLine, type RefundLine, type Refund } from "./retail_refund_calculate_types.ts";
/**
* Refund returned units at what they were actually paid: the basket discount
* is shared across every sale line first, then each line refunds by the
* difference of cumulative shares, so a line fully returned in several visits
* refunds exactly what it cost.
*/
export function calculateRefund(
lines: readonly SaleLine[],
basketDiscount: Money,
returns: readonly ReturnLine[],
): Refund {
if (lines.length === 0) throw new RangeError("a sale needs at least one line");
const currency = lines[0].net.currency;
for (const line of lines) {
if (line.net.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${line.net.currency}`);
if (line.quantity < 1) throw new RangeError(`quantity must be 1 or more, received ${line.quantity}`);
if (line.net.minor < 0) throw new RangeError(`net must not be negative, received ${line.net.minor}`);
if (line.returnedBefore < 0 || line.returnedBefore > line.quantity) {
throw new RangeError(`returnedBefore must be from 0 to ${line.quantity}, received ${line.returnedBefore}`);
}
}
if (basketDiscount.currency !== currency) {
throw new RangeError(`currency mismatch: ${currency} and ${basketDiscount.currency}`);
}
const basket = sumMoney(lines.map((l) => l.net), currency);
if (basketDiscount.minor < 0) throw new RangeError(`basketDiscount must not be negative, received ${basketDiscount.minor}`);
if (basketDiscount.minor > basket.minor) {
throw new RangeError(`basketDiscount ${basketDiscount.minor} is more than the lines' total ${basket.minor}`);
}
const shares =
basketDiscount.minor === 0
? lines.map(() => money(0, currency))
: allocate(basketDiscount, lines.map((l) => l.net.minor));
const paid = lines.map((l, i) => l.net.minor - shares[i].minor);
const seen = new Set<number>();
const refunded: RefundLine[] = returns.map((r) => {
if (!Number.isInteger(r.line) || r.line < 0 || r.line >= lines.length) {
throw new RangeError(`no line ${r.line} in the sale`);
}
if (seen.has(r.line)) throw new RangeError(`line ${r.line} is returned twice in one refund`);
seen.add(r.line);
if (!Number.isInteger(r.quantity) || r.quantity < 1) {
throw new RangeError(`return quantity must be 1 or more, received ${r.quantity}`);
}
const line = lines[r.line];
if (line.returnedBefore + r.quantity > line.quantity) {
throw new RangeError(
`cannot return ${r.quantity} of line ${r.line}: ${line.quantity} bought, ${line.returnedBefore} already returned`,
);
}
const before = roundDiv(paid[r.line] * line.returnedBefore, line.quantity, "half-up");
const after = roundDiv(paid[r.line] * (line.returnedBefore + r.quantity), line.quantity, "half-up");
return {
line: r.line,
sku: line.sku,
quantity: r.quantity,
paid: money(paid[r.line], currency),
amount: money(after - before, currency),
};
});
return { lines: refunded, total: sumMoney(refunded.map((r) => r.amount), currency) };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add retail.refund-calculate
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./retail.refund-calculate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull retail.refund-calculate@1.0.0:typescript.
The whole function, every language, is one file too: retail.refund-calculate-1.0.0.fune, 29,559 bytes, sha256 1df6a683263a2e3f77ad9a3d4985b7433ada680a5e0188e673c69be3b18822e1. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before retail.refund-calculate
after — your function gets the result and the arguments, and returns the final result.
// fune: after retail.refund-calculate
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-div in retail.refund-calculate
// fune: replace money.allocate in retail.refund-calculate
// fune: replace money.amount in retail.refund-calculate
// fune: replace money.sum in retail.refund-calculate
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show retail.refund-calculate --steps.
// fune: step retail.refund-calculate after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| one of three teas, with its share of a 5.00 coupon | lines ×3, £5.00, returns ×1 | → | lines ×1, total £4.29 |
| a single mug refunds its whole paid price | lines ×3, £5.00, returns ×1 | → | lines ×1, total £6.86 |
| one of two books; the coupon's spare penny went to the book line | lines ×3, £5.00, returns ×1 | → | lines ×1, total £5.14 |
| returning everything refunds exactly what was paid | lines ×3, £5.00, returns ×3 | → | lines ×3, total £30.00 |
| the second tea of three, after one came back earlier | lines ×3, £5.00, returns ×1 | → | lines ×1, total £4.28 |
| the last tea: 4.29 + 4.28 + 4.29 is exactly 12.86 | lines ×3, £5.00, returns ×1 | → | lines ×1, total £4.29 |
| returns are listed in the order given | lines ×3, £5.00, returns ×2 | → | lines ×2, total £13.71 |
| no basket discount refunds the line net share | lines ×3, £0.00, returns ×1 | → | lines ×1, total £5.00 |
| nothing returned refunds nothing | lines ×3, £5.00, | → | lines , total £0.00 |
| a discount equal to the whole basket leaves nothing to refund | lines ×3, £35.00, returns ×1 | → | lines ×1, total £0.00 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| returning more than is left is an error | lines ×1, £0.00, returns ×1 | → | error: cannot return 2 of line 0: 3 bought, 2 already returned |
| an unknown line is an error | lines ×3, £0.00, returns ×1 | → | error: no line 5 in the sale |
| the same line twice in one return is an error | lines ×3, £0.00, returns ×2 | → | error: line 0 is returned twice in one refund |
| a discount bigger than the basket is an error | lines ×3, £40.00, returns ×1 | → | error: basketDiscount 4000 is more than the lines' total 3500 |
| a discount in another currency is an error | lines ×3, €5.00, returns ×1 | → | error: currency mismatch: GBP and EUR |
| a zero return quantity is an error | lines ×3, £0.00, returns ×1 | → | error: return quantity must be 1 or more |
More from the author
1. **Share the basket discount across every line of the sale**, returned or not, in proportion to each line's net, with `money.allocate`. A £5 coupon on a £35 basket takes £2.14 off the £15 line, £1.14 off the £8 line and £1.72 off the £12 line (the spare penny goes to the largest remainder), so each line's `paid` adds up to exactly what was taken at the till. 2. **Refund units of a line by cumulative share.** Returning `q` units when `r` have already come back refunds
`round(paid x (r + q) / quantity) - round(paid x r / quantity)`
rounding half up. Because each refund is the difference of two running totals, returning every unit, in any number of visits, refunds exactly `paid`. A line of three that cost £12.86 refunds £4.29, £4.28 and £4.29, never 3 x £4.29 = £12.87.
The line nets should already include line promotions (`retail.promotion-apply` allocates each deal back to the lines in its groups for exactly this reason), so a returned item from a 3 for 2 is refunded at its share of the deal, not at full price.
The naive refund, unit price times quantity, ignores the coupon and refunds £5.00 for an item that cost £4.29 after it.
## What it does not do
- It does not decide whether a refund is due; returns policies and consumer law (the 14-day cancellation period under the Consumer Contracts Regulations 2013, for instance) are the caller's. - Delivery charges are not refunded here. Under the Consumer Contracts Regulations a trader cancelling a whole distance order refunds the standard delivery too; add it when the whole order comes back. - It does not re-check promotions. If returning an item breaks a deal (one of a BOGOF pair), whether the kept item should now cost more is a policy decision; this refunds what the returned item was actually paid.
## Errors
Returning more units than remain, an unknown line, the same line twice in one return, a basket discount bigger than the basket, or mixed currencies.
Files
| Path | Bytes |
|---|---|
| README.md | 2,243 |
| impl/python.py | 3,608 |
| impl/rust.rs | 5,445 |
| impl/typescript.ts | 3,355 |
| vectors.json | 9,237 |