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.
This commit is contained in:
OpenFUT Agent
2026-08-13 18:58:36 +00:00
parent ee2caa0bb0
commit c8269d0df7
+42
View File
@@ -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<i64> {
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 /// 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 /// item insert fails (e.g. a colliding instance id) the whole redemption rolls
/// back — the entitlement stays unconsumed and no items are persisted. /// 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!(grant_reward(&pool, "club", 500).await.unwrap(), 1500);
assert_eq!(balance(&pool, "club").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);
}
} }