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:
@@ -194,6 +194,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
"/economy/purchase-item",
|
"/economy/purchase-item",
|
||||||
post(routes::economy::post_purchase_item),
|
post(routes::economy::post_purchase_item),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-items",
|
||||||
|
post(routes::economy::post_purchase_items),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/collection/:owned_card_id",
|
"/collection/:owned_card_id",
|
||||||
delete(routes::cards::delete_owned_card),
|
delete(routes::cards::delete_owned_card),
|
||||||
|
|||||||
@@ -144,3 +144,20 @@ pub async fn post_purchase_item(
|
|||||||
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
||||||
Ok(Json(BalanceResponse { balance }))
|
Ok(Json(BalanceResponse { balance }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemsRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-items` — atomic debit + mint several items.
|
||||||
|
pub async fn post_purchase_items(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemsRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|||||||
@@ -252,6 +252,26 @@ pub async fn purchase_item(
|
|||||||
Ok(balance)
|
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
|
/// 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.
|
||||||
@@ -515,4 +535,38 @@ mod tests {
|
|||||||
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
assert_eq!(item_count(&pool, "item-new").await, 0);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ pub struct ImportOwnedCard {
|
|||||||
pub card_id: String,
|
pub card_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportEntitlement {
|
||||||
|
/// Opaque definition reference for one unconsumed entitlement (e.g. a pack
|
||||||
|
/// id as text). Core stores it verbatim; it never interprets the value.
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportSlot {
|
pub struct ImportSlot {
|
||||||
pub owned_item_id: String,
|
pub owned_item_id: String,
|
||||||
@@ -93,6 +100,9 @@ pub struct ProfileImportRequest {
|
|||||||
pub owned: Vec<ImportOwnedCard>,
|
pub owned: Vec<ImportOwnedCard>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub squad: Option<ImportSquad>,
|
pub squad: Option<ImportSquad>,
|
||||||
|
/// Unconsumed entitlements to seed (e.g. from a source's unopened packs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub entitlements: Vec<ImportEntitlement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
@@ -244,6 +254,19 @@ pub async fn apply_profile_import(
|
|||||||
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for e in &req.entitlements {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(&e.definition_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("insert entitlement {}", e.definition_id))?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut squad_slots = 0usize;
|
let mut squad_slots = 0usize;
|
||||||
if let Some(sq) = &req.squad {
|
if let Some(sq) = &req.squad {
|
||||||
let squad_id = Uuid::new_v4().to_string();
|
let squad_id = Uuid::new_v4().to_string();
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
use openfut_core::services::card_db::CardDb;
|
use openfut_core::services::card_db::CardDb;
|
||||||
use openfut_core::services::import::{
|
use openfut_core::services::import::{
|
||||||
apply_profile_import, ImportClub, ImportExtension, ImportOwnedCard, ImportProfile, ImportSlot,
|
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
|
||||||
ImportSquad, ProfileImportRequest,
|
ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn fresh_pool() -> sqlx::SqlitePool {
|
async fn fresh_pool() -> sqlx::SqlitePool {
|
||||||
@@ -79,6 +79,7 @@ fn request(
|
|||||||
},
|
},
|
||||||
owned,
|
owned,
|
||||||
squad,
|
squad,
|
||||||
|
entitlements: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +129,33 @@ async fn imports_profile_club_owned_and_squad_in_one_shot() {
|
|||||||
assert_eq!(stored_import_fp, "fp-happy");
|
assert_eq!(stored_import_fp, "fp-happy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn imports_entitlements_seeds_unopened_packs() {
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let db = CardDb::load("data").unwrap();
|
||||||
|
let ids = valid_ids(2);
|
||||||
|
let ow = owned(&ids);
|
||||||
|
let mut req = request("g_ent", "fp-ent", ow, None);
|
||||||
|
req.entitlements = vec![
|
||||||
|
ImportEntitlement {
|
||||||
|
definition_id: "70".into(),
|
||||||
|
},
|
||||||
|
ImportEntitlement {
|
||||||
|
definition_id: "70".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
apply_profile_import(&pool, &db, &req)
|
||||||
|
.await
|
||||||
|
.expect("import");
|
||||||
|
// Two unconsumed entitlements seeded into packs (opened = 0).
|
||||||
|
assert_eq!(count(&pool, "packs").await, 2);
|
||||||
|
let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unopened, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
||||||
let pool = fresh_pool().await;
|
let pool = fresh_pool().await;
|
||||||
|
|||||||
@@ -2776,3 +2776,25 @@ async fn test_economy_insufficient_funds_fail_closed() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|e| e["definition_id"] == "pack-x"));
|
.any(|e| e["definition_id"] == "pack-x"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_economy_purchase_items_debits_and_mints_all() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "econ-e").await;
|
||||||
|
let (st, buy) = json_post(
|
||||||
|
&app,
|
||||||
|
"/economy/purchase-items",
|
||||||
|
serde_json::json!({
|
||||||
|
"cost": 800,
|
||||||
|
"items": [
|
||||||
|
{"item_id": "bx-1", "card_id": "d-1"},
|
||||||
|
{"item_id": "bx-2", "card_id": "d-2"}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(buy["balance"], 4200);
|
||||||
|
let (_, bal) = json_get(&app, "/economy/balance").await;
|
||||||
|
assert_eq!(bal["balance"], 4200);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user