Functional Weave
Code in Rust

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

  • calculate_refund(lines ×3, £5.00, returns ×1) → lines ×1, total £4.29 one of three teas, with its share of a 5.00 coupon
  • calculate_refund(lines ×3, £5.00, returns ×1) → lines ×1, total £6.86 a single mug refunds its whole paid price
  • calculate_refund(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.

pub fn calculate_refund(lines: &[SaleLine], basket_discount: &Money, returns: &[ReturnLine]) -> Refund
linesSaleLine[]the original sale, every line, whether returned or not
basket_discountMoneydiscounts taken off the whole basket (a coupon, a staff discount); 0 for none
returnsReturnLine[]what is coming back now
returnsRefund

The types it declares, generated into your project

/// One line of the original sale.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaleLine {
    pub sku: String,
    /// units bought, 1 or more
    pub quantity: i64,
    /// what the line cost after its own promotions, before basket discounts
    pub net: Money,
    /// units of this line already refunded by earlier returns
    pub returned_before: i64,
}

/// Units of one sale line being returned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReturnLine {
    /// 0-based index into lines
    pub line: i64,
    /// 1 or more
    pub quantity: i64,
}

/// The refund for one returned line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefundLine {
    pub line: i64,
    pub sku: String,
    pub quantity: i64,
    /// what the whole sale line cost after its share of the basket discount
    pub paid: Money,
    /// the refund for the units returned now
    pub amount: Money,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refund {
    /// in the order the returns were given
    pub lines: Vec<RefundLine>,
    pub total: Money,
}

Your code names it in one line, in the file that uses it

fune!(retail.refund-calculate@^1);  // then call calculate_refund(…)
impl/rust.rs · 150 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_allocate::allocate;  ← from money.allocate ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune

/// 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.
///
/// # Panics
/// Panics on an empty sale, mixed currencies, a discount bigger than the
/// basket, or a return of more units than remain.
pub fn calculate_refund(lines: &[SaleLine], basket_discount: &Money, returns: &[ReturnLine]) -> Refund {
    if lines.is_empty() {
        panic!("a sale needs at least one line");
    }
    let currency = lines[0].net.currency.clone();
    for line in lines {
        if line.net.currency != currency {
            panic!("currency mismatch: {} and {}", currency, line.net.currency);
        }
        if line.quantity < 1 {
            panic!("quantity must be 1 or more, received {}", line.quantity);
        }
        if line.net.minor < 0 {
            panic!("net must not be negative, received {}", line.net.minor);
        }
        if line.returned_before < 0 || line.returned_before > line.quantity {
            panic!(
                "returnedBefore must be from 0 to {}, received {}",
                line.quantity, line.returned_before
            );
        }
    }
    if basket_discount.currency != currency {
        panic!("currency mismatch: {} and {}", currency, basket_discount.currency);
    }
    let nets: Vec<Money> = lines.iter().map(|l| l.net.clone()).collect();
    let basket = sum_money(&nets, &currency);
    if basket_discount.minor < 0 {
        panic!("basketDiscount must not be negative, received {}", basket_discount.minor);
    }
    if basket_discount.minor > basket.minor {
        panic!(
            "basketDiscount {} is more than the lines' total {}",
            basket_discount.minor, basket.minor
        );
    }

    let shares: Vec<Money> = if basket_discount.minor == 0 {
        lines.iter().map(|_| money(0, &currency)).collect()
    } else {
        let ratios: Vec<i64> = lines.iter().map(|l| l.net.minor).collect();
        allocate(basket_discount, &ratios)
    };
    let paid: Vec<i64> = lines
        .iter()
        .zip(shares.iter())
        .map(|(l, s)| l.net.minor - s.minor)
        .collect();

    let mut seen: Vec<i64> = Vec::new();
    let mut refunded: Vec<RefundLine> = Vec::new();
    for r in returns {
        if r.line < 0 || r.line >= lines.len() as i64 {
            panic!("no line {} in the sale", r.line);
        }
        if seen.contains(&r.line) {
            panic!("line {} is returned twice in one refund", r.line);
        }
        seen.push(r.line);
        if r.quantity < 1 {
            panic!("return quantity must be 1 or more, received {}", r.quantity);
        }
        let index = r.line as usize;
        let line = &lines[index];
        if line.returned_before + r.quantity > line.quantity {
            panic!(
                "cannot return {} of line {}: {} bought, {} already returned",
                r.quantity, r.line, line.quantity, line.returned_before
            );
        }
        let before = round_div(paid[index] * line.returned_before, line.quantity, "half-up");
        let after = round_div(
            paid[index] * (line.returned_before + r.quantity),
            line.quantity,
            "half-up",
        );
        refunded.push(RefundLine {
            line: r.line,
            sku: line.sku.clone(),
            quantity: r.quantity,
            paid: money(paid[index], &currency),
            amount: money(after - before, &currency),
        });
    }

    let amounts: Vec<Money> = refunded.iter().map(|r| r.amount.clone()).collect();
    Refund {
        total: sum_money(&amounts, &currency),
        lines: refunded,
    }
}

pub fn sale_line_from_value(v: &Value) -> SaleLine {
    SaleLine {
        sku: v.get("sku").as_str().to_string(),
        quantity: v.get("quantity").as_i64(),
        net: money_from_value(v.get("net")),
        returned_before: v.get("returnedBefore").as_i64(),
    }
}

pub fn refund_to_value(r: &Refund) -> Value {
    Value::obj(vec![
        (
            "lines",
            Value::Arr(
                r.lines
                    .iter()
                    .map(|l| {
                        Value::obj(vec![
                            ("line", Value::Int(l.line)),
                            ("sku", Value::str(&l.sku)),
                            ("quantity", Value::Int(l.quantity)),
                            ("paid", money_to_value(&l.paid)),
                            ("amount", money_to_value(&l.amount)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("total", money_to_value(&r.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let lines: Vec<SaleLine> = args[0].as_arr().iter().map(sale_line_from_value).collect();
    let returns: Vec<ReturnLine> = args[2]
        .as_arr()
        .iter()
        .map(|v| ReturnLine {
            line: v.get("line").as_i64(),
            quantity: v.get("quantity").as_i64(),
        })
        .collect();
    refund_to_value(&calculate_refund(&lines, &money_from_value(&args[1]), &returns))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add retail.refund-calculate
Download for Rust retail.refund-calculate-1.0.0-rust.fune · 22,367 bytes sha256 a3beb691b59284ddd34e86f5e0a0e12792acfdac9d366ae3610c60fe20c8326b

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./retail.refund-calculate-1.0.0-rust.fune, or fetch it from a terminal with fune pull retail.refund-calculate@1.0.0:rust.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,243
impl/python.py3,608
impl/rust.rs5,445
impl/typescript.ts3,355
vectors.json9,237