feat(economy): purchase_items primitive + entitlement import seeding
purchase_items: generic atomic debit + mint of several items (fail-closed) for open-on-buy Store packs (Store BUY returns items immediately) + POST /economy/purchase-items route. Import: add optional entitlements[] to ProfileImportRequest, seeding unconsumed packs rows in the same transaction (so a source's unopened packs become Core entitlements); idempotency via the existing import fingerprint. Tests: 2 purchase_items unit + endpoint + entitlement-seed import. Core matrix 45 lib + 116 integration + 9 import green; clippy clean.
This commit is contained in:
@@ -252,6 +252,26 @@ pub async fn purchase_item(
|
||||
Ok(balance)
|
||||
}
|
||||
|
||||
/// Debit `cost` and mint several owned items, atomically. Fail-closed: if the
|
||||
/// club cannot afford `cost`, nothing is debited and no items are added; if any
|
||||
/// item insert fails the whole purchase rolls back. This is the "buy + open"
|
||||
/// primitive (Store packs that open on purchase): one debit paired with the
|
||||
/// minted pack contents. Returns the post-debit balance.
|
||||
pub async fn purchase_items(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
cost: i64,
|
||||
items: &[GrantedItem],
|
||||
) -> AppResult<i64> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let balance = debit(&mut tx, club_id, cost).await?;
|
||||
for item in items {
|
||||
add_item(&mut tx, club_id, &item.item_id, &item.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.
|
||||
@@ -515,4 +535,38 @@ mod tests {
|
||||
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||
assert_eq!(item_count(&pool, "item-new").await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purchase_items_debits_and_mints_all() {
|
||||
let pool = fixture().await;
|
||||
let items = vec![
|
||||
GrantedItem {
|
||||
item_id: "p-1".into(),
|
||||
card_id: "d-1".into(),
|
||||
},
|
||||
GrantedItem {
|
||||
item_id: "p-2".into(),
|
||||
card_id: "d-2".into(),
|
||||
},
|
||||
];
|
||||
let bal = purchase_items(&pool, "club", 700, &items).await.unwrap();
|
||||
assert_eq!(bal, 300);
|
||||
assert_eq!(item_count(&pool, "p-1").await, 1);
|
||||
assert_eq!(item_count(&pool, "p-2").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purchase_items_insufficient_funds_rolls_back() {
|
||||
let pool = fixture().await;
|
||||
let items = vec![GrantedItem {
|
||||
item_id: "p-1".into(),
|
||||
card_id: "d-1".into(),
|
||||
}];
|
||||
let err = purchase_items(&pool, "club", 9000, &items)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||
assert_eq!(item_count(&pool, "p-1").await, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user