Step 3 of 6
SHOP-210: orders with validation
SHOP-210: Validate orders before taking stock
Last week an order for 3 mugs and 1 unknown product reduced the mug stock and then failed. Support had to fix the numbers by hand. New rule: an order either succeeds completely or changes nothing, and every failure comes with a clear reason the storefront can show.
Two ideas carry this ticket:
- Validate everything first, then act. Check every line of the order against the inventory without changing anything. Only when all checks pass, take the stock and build the receipt. Then a failure can't leave things half-done. (Databases call this a transaction; in memory, ordering your code this way is enough.)
- A library-style error type. The order module will be used by the storefront, the admin tool and the batch importer, each showing errors differently. So it returns a precise enum, made with thiserror (module 39):
#[derive(Error)]writes theDisplayandstd::error::Errorimpls from the#[error("...")]attributes.
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
enum TransferError {
#[error("amount must be positive")]
NotPositive,
#[error("account {0} not found")]
UnknownAccount(String),
#[error("insufficient funds: balance {balance}, needed {needed}")]
Insufficient { balance: u64, needed: u64 },
}
fn check(balance: u64, account: &str, amount: u64) -> Result<u64, TransferError> {
if amount == 0 {
return Err(TransferError::NotPositive);
}
if account != "ACC-1" {
return Err(TransferError::UnknownAccount(account.to_string()));
}
if amount > balance {
return Err(TransferError::Insufficient { balance, needed: amount });
}
Ok(balance - amount)
}
fn main() {
println!("{:?}", check(500, "ACC-1", 200));
for (account, amount) in [("ACC-1", 0), ("ACC-9", 10), ("ACC-1", 900)] {
if let Err(e) = check(500, account, amount) {
println!("{e}");
}
}
}
Ok(300)
amount must be positive
account ACC-9 not found
insufficient funds: balance 500, needed 900
{0} in an #[error] message is the first field of a tuple variant, and {balance} a named field of a struct variant.
Common mistakes
- Mutating while validating. Any
stock -= ...before the last check is a bug waiting for the second line of an order to fail. - Checking lines one at a time when the same product appears twice.
[("MUG", 2), ("MUG", 2)]with 3 mugs in stock passes two separate checks but needs 4. Add up per product first. unwrap()on lookups you "know" succeed. After validation it's true, but say so:.expect("validated above")documents the reasoning and gives a clear message if someone later breaks it.
Your task
Implement Inventory::place_order(&mut self, order: &[(&str, u32)]) -> Result<Receipt, OrderError>. Each order line is (sku, quantity). In this order:
- An empty order is
OrderError::Empty. - Going through the lines in order, the first line with quantity 0 gives
ZeroQuantity(sku), and the first unknown SKU givesUnknownSku(sku)(whichever line comes first; a line with both problems givesZeroQuantity). - Add up the quantities per SKU. The first SKU (in order of first appearance) whose total exceeds its stock gives
OutOfStock { sku, requested: total, available: stock }. - Only then reduce the stock and return a
Receiptwith oneReceiptLineper order line (product name, quantity, quantity times price) in order, and the total.
On any error, nothing in the inventory changes.
Challenge 2
SHOP-214: Low stock report
Purchasing wants a daily list of products that are running low, the emptiest first.
Add fn low_stock(&self, threshold: u32) -> Vec<String> to Inventory: one line per product whose stock is below threshold, formatted as SKU (Name): N left, sorted by stock (lowest first) and then by SKU. The inventory is a HashMap, so sorting is what makes the report deterministic.
Challenge 3
SHOP-220: Discounts at checkout
Marketing keeps inventing new kinds of discounts. Checkout shouldn't change every time: make discounts pluggable.
The seed defines a Discount trait with label() and amount_off(subtotal_cents, items). Implement it for three types:
PercentOff { percent }:subtotal * percent / 100(rounded down); label10% offBulkDeal { min_items, off_cents }:off_centswhenitems >= min_items, otherwise 0; labelbulk deal (5+ items)Coupon { code, off_cents }: alwaysoff_cents; labelcoupon CRAB5
Then write fn checkout(subtotal_cents: u64, items: u32, discounts: &[Box<dyn Discount>]) -> (u64, Vec<String>). Every discount is computed from the original subtotal (they don't stack on each other), the total discount can't make the price negative, and the returned labels are those of the discounts that took something off (amount above 0), in order.
Previous: LOG-102: the daily traffic report Next: OPS-330: validate the deploy config