From bcc4f5104a329c4c9c8cfc999587741d55fd3295 Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 19:27:06 +0000 Subject: [PATCH] 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. --- src/app.rs | 4 +++ src/routes/economy.rs | 17 ++++++++++++ src/services/economy.rs | 54 ++++++++++++++++++++++++++++++++++++ src/services/import.rs | 23 +++++++++++++++ tests/import_service_test.rs | 32 +++++++++++++++++++-- tests/integration_test.rs | 22 +++++++++++++++ 6 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7bbd617..df9679a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -194,6 +194,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { "/economy/purchase-item", post(routes::economy::post_purchase_item), ) + .route( + "/economy/purchase-items", + post(routes::economy::post_purchase_items), + ) .route( "/collection/:owned_card_id", delete(routes::cards::delete_owned_card), diff --git a/src/routes/economy.rs b/src/routes/economy.rs index e5e8783..f3408a3 100644 --- a/src/routes/economy.rs +++ b/src/routes/economy.rs @@ -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?; Ok(Json(BalanceResponse { balance })) } + +#[derive(Deserialize)] +pub struct PurchaseItemsRequest { + pub cost: i64, + pub items: Vec, +} + +/// `POST /economy/purchase-items` — atomic debit + mint several items. +pub async fn post_purchase_items( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let club = resolve_club(&state, &game).await?; + let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?; + Ok(Json(BalanceResponse { balance })) +} diff --git a/src/services/economy.rs b/src/services/economy.rs index 7e644f7..e0504b5 100644 --- a/src/services/economy.rs +++ b/src/services/economy.rs @@ -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 { + 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); + } } diff --git a/src/services/import.rs b/src/services/import.rs index a360bea..f170ff6 100644 --- a/src/services/import.rs +++ b/src/services/import.rs @@ -51,6 +51,13 @@ pub struct ImportOwnedCard { 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)] pub struct ImportSlot { pub owned_item_id: String, @@ -93,6 +100,9 @@ pub struct ProfileImportRequest { pub owned: Vec, #[serde(default)] pub squad: Option, + /// Unconsumed entitlements to seed (e.g. from a source's unopened packs). + #[serde(default)] + pub entitlements: Vec, } #[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))?; } + 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; if let Some(sq) = &req.squad { let squad_id = Uuid::new_v4().to_string(); diff --git a/tests/import_service_test.rs b/tests/import_service_test.rs index a41c6f6..e8cba1d 100644 --- a/tests/import_service_test.rs +++ b/tests/import_service_test.rs @@ -4,8 +4,8 @@ use openfut_core::services::card_db::CardDb; use openfut_core::services::import::{ - apply_profile_import, ImportClub, ImportExtension, ImportOwnedCard, ImportProfile, ImportSlot, - ImportSquad, ProfileImportRequest, + apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard, + ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest, }; async fn fresh_pool() -> sqlx::SqlitePool { @@ -79,6 +79,7 @@ fn request( }, owned, 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"); } +#[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] async fn rerun_same_fingerprint_is_idempotent_noop() { let pool = fresh_pool().await; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index e81184c..a1fcc75 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2776,3 +2776,25 @@ async fn test_economy_insufficient_funds_fail_closed() { .iter() .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); +}