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:
OpenFUT Agent
2026-08-13 19:27:06 +00:00
parent d32dc6e3ae
commit bcc4f5104a
6 changed files with 150 additions and 2 deletions
+4
View File
@@ -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),
+17
View File
@@ -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 }))
}
+54
View File
@@ -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);
}
} }
+23
View File
@@ -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();
+30 -2
View File
@@ -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;
+22
View File
@@ -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);
}