From c8269d0df798aa9aa7bbcf7ad3e0951a69e10800 Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 18:58:36 +0000 Subject: [PATCH] feat(economy): add generic purchase_item (atomic debit + mint) The FIFA17 economy audit proved the transfer market is synthetic-seller: buy-now mints a new owned item and debits the buyer; no real counterparty, no sale-credit/expiry/fee. The generic primitive that models this is an atomic debit + inventory add (purchase_item), NOT a two-party transfer_item_with_payment (which would be unused). Fail-closed: an unaffordable purchase debits nothing and mints nothing. 2 unit tests. --- src/services/economy.rs | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/services/economy.rs b/src/services/economy.rs index c7b883e..1e78f90 100644 --- a/src/services/economy.rs +++ b/src/services/economy.rs @@ -212,6 +212,25 @@ pub async fn purchase_entitlement( }) } +/// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club +/// cannot afford `cost`, nothing is debited and no item is added. This is the +/// "buy a specific item" primitive (a debit paired with an inventory add), for +/// synthetic-seller markets where the purchased item is minted rather than +/// transferred from another owner. Returns the post-debit balance. +pub async fn purchase_item( + pool: &Pool, + club_id: &str, + cost: i64, + item_id: &str, + card_id: &str, +) -> AppResult { + let mut tx = pool.begin().await?; + let balance = debit(&mut tx, club_id, cost).await?; + add_item(&mut tx, club_id, item_id, card_id).await?; + tx.commit().await?; + Ok(balance) +} + /// Consume an entitlement once and add its granted items, atomically. If any /// item insert fails (e.g. a colliding instance id) the whole redemption rolls /// back — the entitlement stays unconsumed and no items are persisted. @@ -452,4 +471,27 @@ mod tests { assert_eq!(grant_reward(&pool, "club", 500).await.unwrap(), 1500); assert_eq!(balance(&pool, "club").await.unwrap(), 1500); } + + #[tokio::test] + async fn purchase_item_debits_and_mints() { + let pool = fixture().await; + let bal = purchase_item(&pool, "club", 400, "item-new", "def-new") + .await + .unwrap(); + assert_eq!(bal, 600); + assert_eq!(balance(&pool, "club").await.unwrap(), 600); + assert_eq!(item_count(&pool, "item-new").await, 1); + } + + #[tokio::test] + async fn purchase_item_insufficient_funds_rolls_back() { + let pool = fixture().await; + let err = purchase_item(&pool, "club", 9000, "item-new", "def-new") + .await + .unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + // Nothing debited, no item minted. + assert_eq!(balance(&pool, "club").await.unwrap(), 1000); + assert_eq!(item_count(&pool, "item-new").await, 0); + } }