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); + } }