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:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user