From dfa807615f0944584f30cb00f6c63c83645c454a Mon Sep 17 00:00:00 2001 From: Ibinola Date: Tue, 23 Jun 2026 10:41:32 +0000 Subject: [PATCH] feat(order): add price snapshot and restaurant-scoped validation to place_order() - Add MenuItem struct: on-chain authoritative menu item registry keyed by (restaurant_id, menu_item_id) - Add MenuItemSnapshot struct: immutable price record captured at order time - Add DataKey::MenuItem(u64, u64) storage key - Add register_menu_item(), remove_menu_item(), get_menu_item() admin functions - Update OrderItem to include snapshot field with price_at_order - Rewrite place_order() to: - Validate each menu_item_id belongs to the restaurant_id via on-chain registry - Panic with 'item not on menu' if validation fails - Ignore caller-supplied unit_price; always use authoritative registry price - Capture MenuItemSnapshot for each item in persistent storage - Add 9 unit tests (all passing): - Caller-supplied unit_price is ignored; registry price is used - price_at_order snapshot is stored correctly - Item from wrong restaurant panics with 'item not on menu' - Unregistered item panics with 'item not on menu' - All existing lifecycle tests updated to register menu items first Closes #287 --- contracts/order/src/lib.rs | 300 +++++++++++++++++++++++++++++++------ 1 file changed, 255 insertions(+), 45 deletions(-) diff --git a/contracts/order/src/lib.rs b/contracts/order/src/lib.rs index f390f13..d112dad 100644 --- a/contracts/order/src/lib.rs +++ b/contracts/order/src/lib.rs @@ -39,7 +39,30 @@ pub enum OrderStatus { Cancelled, } +/// On-chain menu item registration — the authoritative source of price truth. +#[contracttype] +#[derive(Clone)] +pub struct MenuItem { + pub menu_item_id: u64, + pub restaurant_id: u64, + pub name: String, + /// Authoritative price in stroops (1 XLM = 10_000_000 stroops). + pub price: i128, +} + +/// Price snapshot captured at order time — immutable historical record. +#[contracttype] +#[derive(Clone)] +pub struct MenuItemSnapshot { + pub menu_item_id: u64, + pub name: String, + pub price_at_order: i128, +} + /// A single line-item in an order. +/// +/// `unit_price` mirrors `snapshot.price_at_order` for convenience; the +/// snapshot is the authoritative record. #[contracttype] #[derive(Clone)] pub struct OrderItem { @@ -49,8 +72,11 @@ pub struct OrderItem { pub name: String, /// Number of portions ordered. pub quantity: u32, - /// Price per unit in stroops (1 XLM = 10 000 000 stroops). + /// Authoritative price per unit captured from the on-chain registry at + /// order time. Caller-supplied values are ignored. pub unit_price: i128, + /// Full snapshot: name + authoritative price at order time. + pub snapshot: MenuItemSnapshot, } /// A complete order stored on-chain. @@ -61,7 +87,7 @@ pub struct Order { pub restaurant_id: u64, pub customer: Address, pub items: Vec, - /// Sum of (quantity * unit_price) for all items, in stroops. + /// Sum of (quantity * snapshot.price_at_order) for all items, in stroops. pub total_amount: i128, pub status: OrderStatus, pub created_at: u64, @@ -83,6 +109,8 @@ pub enum DataKey { RestaurantOrders(u64), /// Ordered list of order IDs for a customer. CustomerOrders(Address), + /// Menu item keyed by (restaurant_id, menu_item_id). + MenuItem(u64, u64), } // --------------------------------------------------------------------------- @@ -108,20 +136,92 @@ impl OrderContract { env.storage().instance().extend_ttl(17_280, 17_280); } + // ----------------------------------------------------------------------- + // Menu registry (admin only) + // ----------------------------------------------------------------------- + + /// Register or update a menu item with its authoritative price. + /// + /// Only the admin may call this. In production the restaurant registry + /// could be consulted to allow restaurant owners to manage their own menus. + pub fn register_menu_item( + env: Env, + caller: Address, + restaurant_id: u64, + menu_item_id: u64, + name: String, + price: i128, + ) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + + if price <= 0 { + panic!("price must be positive"); + } + + let item = MenuItem { + menu_item_id, + restaurant_id, + name, + price, + }; + + let ttl: u32 = 2_073_600; + env.storage() + .persistent() + .set(&DataKey::MenuItem(restaurant_id, menu_item_id), &item); + env.storage() + .persistent() + .extend_ttl(&DataKey::MenuItem(restaurant_id, menu_item_id), ttl, ttl); + } + + /// Remove a menu item from a restaurant's menu (admin only). + pub fn remove_menu_item( + env: Env, + caller: Address, + restaurant_id: u64, + menu_item_id: u64, + ) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage() + .persistent() + .remove(&DataKey::MenuItem(restaurant_id, menu_item_id)); + } + + /// Fetch a registered menu item. + pub fn get_menu_item(env: Env, restaurant_id: u64, menu_item_id: u64) -> MenuItem { + env.storage() + .persistent() + .get(&DataKey::MenuItem(restaurant_id, menu_item_id)) + .unwrap_or_else(|| panic!("item not on menu")) + } + // ----------------------------------------------------------------------- // Customer actions // ----------------------------------------------------------------------- /// Place a new order. /// + /// For each item, the contract validates that `menu_item_id` belongs to + /// `restaurant_id` in the on-chain registry and captures the authoritative + /// price as a `MenuItemSnapshot`. Any caller-supplied `unit_price` is + /// ignored — the registry price is always used. + /// /// # Arguments /// - `customer` – wallet placing the order (must sign the tx). /// - `restaurant_id` – target restaurant (registered in the registry). - /// - `items` – non-empty list of line items. + /// - `items` – non-empty list of line items (only `menu_item_id` + /// and `quantity` are read; `unit_price` is ignored). /// - `notes` – optional delivery / allergy notes. /// /// # Returns /// The auto-assigned order ID. + /// + /// # Panics + /// - `"order must contain at least one item"` + /// - `"quantity must be greater than zero"` + /// - `"item not on menu"` – if the item does not exist for this restaurant pub fn place_order( env: Env, customer: Address, @@ -135,16 +235,39 @@ impl OrderContract { panic!("order must contain at least one item"); } - // Compute total from items. + let ttl: u32 = 2_073_600; + let mut validated_items: Vec = vec![&env]; let mut total: i128 = 0; + for item in items.iter() { if item.quantity == 0 { panic!("quantity must be greater than zero"); } - if item.unit_price <= 0 { - panic!("unit price must be positive"); - } - total += item.unit_price * item.quantity as i128; + + // Validate item belongs to this restaurant and retrieve authoritative price. + let menu_item: MenuItem = env + .storage() + .persistent() + .get(&DataKey::MenuItem(restaurant_id, item.menu_item_id)) + .unwrap_or_else(|| panic!("item not on menu")); + + let price_at_order = menu_item.price; + let snapshot = MenuItemSnapshot { + menu_item_id: item.menu_item_id, + name: menu_item.name.clone(), + price_at_order, + }; + + let validated_item = OrderItem { + menu_item_id: item.menu_item_id, + name: menu_item.name, + quantity: item.quantity, + unit_price: price_at_order, // always from registry, never from caller + snapshot, + }; + + total += price_at_order * item.quantity as i128; + validated_items.push_back(validated_item); } let count: u64 = env.storage().instance().get(&DataKey::Count).unwrap_or(0); @@ -155,7 +278,7 @@ impl OrderContract { id, restaurant_id, customer: customer.clone(), - items: items.clone(), + items: validated_items, total_amount: total, status: OrderStatus::Pending, created_at: now, @@ -163,7 +286,6 @@ impl OrderContract { notes, }; - let ttl: u32 = 2_073_600; env.storage().persistent().set(&DataKey::Order(id), &order); env.storage() .persistent() @@ -354,38 +476,133 @@ mod test { use soroban_sdk::testutils::Address as _; use soroban_sdk::{vec, Env, String}; - fn make_item(env: &Env, id: u64, qty: u32, price: i128) -> OrderItem { + /// Build a minimal OrderItem. `unit_price` is intentionally wrong to prove + /// place_order ignores it and uses the registry price instead. + fn make_item(env: &Env, id: u64, qty: u32) -> OrderItem { + let snap = MenuItemSnapshot { + menu_item_id: id, + name: String::from_str(env, ""), + price_at_order: 0, + }; OrderItem { menu_item_id: id, - name: String::from_str(env, "Jollof Rice"), + name: String::from_str(env, ""), quantity: qty, - unit_price: price, + unit_price: 1, // deliberately wrong — should be ignored + snapshot: snap, } } - fn setup() -> (Env, OrderContractClient<'static>) { + fn setup() -> (Env, OrderContractClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); let cid = env.register(OrderContract, ()); let client = OrderContractClient::new(&env, &cid); - (env, client) + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, admin) } + /// Register a menu item for a restaurant and return its registered price. + fn register( + client: &OrderContractClient, + admin: &Address, + env: &Env, + restaurant_id: u64, + item_id: u64, + price: i128, + ) { + client.register_menu_item( + admin, + &restaurant_id, + &item_id, + &String::from_str(env, "Jollof Rice"), + &price, + ); + } + + // ----------------------------------------------------------------------- + // Price snapshot tests + // ----------------------------------------------------------------------- + #[test] - fn test_place_and_get_order() { - let (env, client) = setup(); - let admin = Address::generate(&env); + fn test_place_order_uses_registry_price_not_caller_price() { + let (env, client, admin) = setup(); let customer = Address::generate(&env); - client.initialize(&admin); + // Register item with authoritative price 50_000_000 stroops. + register(&client, &admin, &env, 1, 10, 50_000_000); - let items = vec![&env, make_item(&env, 1, 2, 5_000_000)]; // 2 × 0.5 XLM - let id = client.place_order( - &customer, - &42, - &items, - &String::from_str(&env, "No onions please"), - ); + // Customer submits unit_price=1 (far too low) — should be ignored. + let items = vec![&env, make_item(&env, 10, 1)]; + let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); + + let order = client.get_order(&id); + // Total must use registry price, not caller-supplied 1. + assert_eq!(order.total_amount, 50_000_000); + assert_eq!(order.items.get(0).unwrap().unit_price, 50_000_000); + assert_eq!(order.items.get(0).unwrap().snapshot.price_at_order, 50_000_000); + } + + #[test] + fn test_snapshot_stored_in_order_item() { + let (env, client, admin) = setup(); + let customer = Address::generate(&env); + + register(&client, &admin, &env, 1, 42, 7_000_000); + + let items = vec![&env, make_item(&env, 42, 2)]; + let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); + + let order = client.get_order(&id); + let item = order.items.get(0).unwrap(); + assert_eq!(item.snapshot.menu_item_id, 42); + assert_eq!(item.snapshot.price_at_order, 7_000_000); + // Total = 2 × 7_000_000 + assert_eq!(order.total_amount, 14_000_000); + } + + // ----------------------------------------------------------------------- + // Restaurant-scoped validation tests + // ----------------------------------------------------------------------- + + #[test] + #[should_panic(expected = "item not on menu")] + fn test_item_not_belonging_to_restaurant_panics() { + let (env, client, admin) = setup(); + let customer = Address::generate(&env); + + // Register item 5 for restaurant 1, NOT for restaurant 2. + register(&client, &admin, &env, 1, 5, 10_000_000); + + // Try to order item 5 for restaurant 2 → must panic. + let items = vec![&env, make_item(&env, 5, 1)]; + client.place_order(&customer, &2, &items, &String::from_str(&env, "")); + } + + #[test] + #[should_panic(expected = "item not on menu")] + fn test_unregistered_item_panics() { + let (env, client, _admin) = setup(); + let customer = Address::generate(&env); + + // No items registered at all. + let items = vec![&env, make_item(&env, 99, 1)]; + client.place_order(&customer, &1, &items, &String::from_str(&env, "")); + } + + // ----------------------------------------------------------------------- + // Existing behaviour preserved + // ----------------------------------------------------------------------- + + #[test] + fn test_place_and_get_order() { + let (env, client, admin) = setup(); + let customer = Address::generate(&env); + + register(&client, &admin, &env, 42, 1, 5_000_000); + let items = vec![&env, make_item(&env, 1, 2)]; + let id = client.place_order(&customer, &42, &items, &String::from_str(&env, "No onions")); assert_eq!(id, 1); let order = client.get_order(&id); @@ -395,35 +612,30 @@ mod test { #[test] fn test_advance_status() { - let (env, client) = setup(); - let admin = Address::generate(&env); + let (env, client, admin) = setup(); let customer = Address::generate(&env); - client.initialize(&admin); - let items = vec![&env, make_item(&env, 1, 1, 7_000_000)]; + register(&client, &admin, &env, 1, 1, 7_000_000); + let items = vec![&env, make_item(&env, 1, 1)]; let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); client.advance_status(&admin, &id); assert_eq!(client.get_order(&id).status, OrderStatus::Confirmed); - client.advance_status(&admin, &id); assert_eq!(client.get_order(&id).status, OrderStatus::Preparing); - client.advance_status(&admin, &id); assert_eq!(client.get_order(&id).status, OrderStatus::Ready); - client.advance_status(&admin, &id); assert_eq!(client.get_order(&id).status, OrderStatus::Delivered); } #[test] fn test_customer_cancel_pending() { - let (env, client) = setup(); - let admin = Address::generate(&env); + let (env, client, admin) = setup(); let customer = Address::generate(&env); - client.initialize(&admin); - let items = vec![&env, make_item(&env, 2, 1, 3_000_000)]; + register(&client, &admin, &env, 1, 2, 3_000_000); + let items = vec![&env, make_item(&env, 2, 1)]; let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); client.cancel_order(&customer, &id); @@ -433,12 +645,11 @@ mod test { #[test] #[should_panic(expected = "customers may only cancel pending orders")] fn test_customer_cannot_cancel_confirmed() { - let (env, client) = setup(); - let admin = Address::generate(&env); + let (env, client, admin) = setup(); let customer = Address::generate(&env); - client.initialize(&admin); - let items = vec![&env, make_item(&env, 1, 1, 5_000_000)]; + register(&client, &admin, &env, 1, 1, 5_000_000); + let items = vec![&env, make_item(&env, 1, 1)]; let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); client.advance_status(&admin, &id); client.cancel_order(&customer, &id); @@ -446,12 +657,11 @@ mod test { #[test] fn test_get_restaurant_orders() { - let (env, client) = setup(); - let admin = Address::generate(&env); + let (env, client, admin) = setup(); let customer = Address::generate(&env); - client.initialize(&admin); - let items = vec![&env, make_item(&env, 1, 1, 5_000_000)]; + register(&client, &admin, &env, 7, 1, 5_000_000); + let items = vec![&env, make_item(&env, 1, 1)]; client.place_order(&customer, &7, &items.clone(), &String::from_str(&env, "")); client.place_order(&customer, &7, &items, &String::from_str(&env, ""));