From ee2caa0bb080589598bdf66b55e390a1ca3667ad Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 18:44:51 +0000 Subject: [PATCH] feat(economy): generic atomic profile-economy authority Add a game-agnostic economy service that exposes atomic, fail-closed operations over Core's existing durable tables rather than forking a parallel persistence stack: * currency ledger -> clubs.coins * owned inventory -> owned_cards * entitlements -> packs (opaque definition_id, consume-once `opened`) Compound operations run inside a single SQLite transaction, closing the atomicity gap in the pool-scoped club::{spend,add}_coins helpers whose read/modify/write spans multiple round-trips. Public ops: balance, purchase_entitlement (debit+grant), redeem_entitlement (consume-once + add items, all-or-nothing), sell_item (remove+credit), grant_reward (credit). Deliberately game-neutral: currency names, entitlement/pack ids, and per-save item-id sequences stay in the per-game adapter that drives these primitives. 9 unit tests cover debit/credit fail-closed rollback, consume-once, partial-redeem rollback, and non-negative guards. --- src/services/economy.rs | 455 ++++++++++++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + 2 files changed, 456 insertions(+) create mode 100644 src/services/economy.rs diff --git a/src/services/economy.rs b/src/services/economy.rs new file mode 100644 index 0000000..c7b883e --- /dev/null +++ b/src/services/economy.rs @@ -0,0 +1,455 @@ +//! Generic, game-agnostic economy authority. +//! +//! Exposes atomic, fail-closed economy operations over Core's existing durable +//! tables — it does **not** introduce a parallel persistence stack: +//! +//! * currency ledger -> `clubs.coins` +//! * owned inventory -> `owned_cards` +//! * entitlements -> `packs` (opaque `definition_id` + consume-once `opened`) +//! +//! Every compound operation (purchase, redeem, sell) runs inside a single SQLite +//! transaction, so a partial failure leaves no balance or inventory drift — the +//! pool-scoped helpers in [`crate::services::club`] cannot offer that guarantee +//! because their read/modify/write spans multiple pool round-trips. +//! +//! This module is deliberately game-neutral: currency names, entitlement/pack +//! ids, and per-save item-id sequences are per-game concerns that live in the +//! adapter which drives these primitives, never here. + +use crate::{ + db::Pool, + error::{AppError, AppResult}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::SqliteConnection; +use uuid::Uuid; + +/// An instance to place into a club's inventory when an entitlement is redeemed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrantedItem { + /// Caller-minted opaque instance id. The adapter owns the id scheme; Core + /// treats it as an opaque unique key. + pub item_id: String, + /// Definition reference this instance resolves against. + pub card_id: String, +} + +/// Outcome of a purchase: the post-debit balance and the new entitlement id. +#[derive(Debug, Clone, Serialize)] +pub struct PurchaseReceipt { + pub balance: i64, + pub entitlement_id: String, +} + +// ---- transaction-scoped primitives ------------------------------------------- +// Each takes a live connection (a transaction, reborrowed) so callers can compose +// several into one atomic unit. They never commit; the composed public op does. + +async fn read_balance(conn: &mut SqliteConnection, club_id: &str) -> AppResult { + sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_optional(&mut *conn) + .await? + .ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}"))) +} + +async fn debit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult { + if amount < 0 { + return Err(AppError::BadRequest( + "debit amount must be non-negative".into(), + )); + } + let balance = read_balance(conn, club_id).await?; + if balance < amount { + return Err(AppError::BadRequest(format!( + "insufficient balance: have {balance}, need {amount}" + ))); + } + let now = Utc::now().to_rfc3339(); + sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?") + .bind(amount) + .bind(&now) + .bind(club_id) + .execute(&mut *conn) + .await?; + Ok(balance - amount) +} + +async fn credit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult { + if amount < 0 { + return Err(AppError::BadRequest( + "credit amount must be non-negative".into(), + )); + } + let balance = read_balance(conn, club_id).await?; + let now = Utc::now().to_rfc3339(); + sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") + .bind(amount) + .bind(&now) + .bind(club_id) + .execute(&mut *conn) + .await?; + Ok(balance + amount) +} + +async fn grant_entitlement( + conn: &mut SqliteConnection, + club_id: &str, + definition_id: &str, +) -> AppResult { + // Ensure the club exists so we never orphan an entitlement. + read_balance(conn, club_id).await?; + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)", + ) + .bind(&id) + .bind(club_id) + .bind(definition_id) + .bind(&now) + .execute(&mut *conn) + .await?; + Ok(id) +} + +/// Consume an unopened entitlement exactly once, returning its definition ref. +async fn consume_entitlement( + conn: &mut SqliteConnection, + club_id: &str, + entitlement_id: &str, +) -> AppResult { + let row = sqlx::query_as::<_, (String, i64)>( + "SELECT definition_id, opened FROM packs WHERE id = ? AND club_id = ?", + ) + .bind(entitlement_id) + .bind(club_id) + .fetch_optional(&mut *conn) + .await?; + let (definition_id, opened) = + row.ok_or_else(|| AppError::NotFound(format!("entitlement not found: {entitlement_id}")))?; + if opened != 0 { + return Err(AppError::Conflict(format!( + "entitlement already consumed: {entitlement_id}" + ))); + } + sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ?") + .bind(entitlement_id) + .bind(club_id) + .execute(&mut *conn) + .await?; + Ok(definition_id) +} + +async fn add_item( + conn: &mut SqliteConnection, + club_id: &str, + item_id: &str, + card_id: &str, +) -> AppResult<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \ + VALUES (?, ?, ?, 0, NULL, ?)", + ) + .bind(item_id) + .bind(club_id) + .bind(card_id) + .bind(&now) + .execute(&mut *conn) + .await?; + Ok(()) +} + +async fn remove_item( + conn: &mut SqliteConnection, + club_id: &str, + item_id: &str, +) -> AppResult { + let card_id = sqlx::query_scalar::<_, String>( + "SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?", + ) + .bind(item_id) + .bind(club_id) + .fetch_optional(&mut *conn) + .await? + .ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?; + sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?") + .bind(item_id) + .bind(club_id) + .execute(&mut *conn) + .await?; + Ok(card_id) +} + +// ---- composed atomic operations ---------------------------------------------- + +/// Read a club's current currency balance. +pub async fn balance(pool: &Pool, club_id: &str) -> AppResult { + sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}"))) +} + +/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club +/// cannot afford `cost`, nothing is debited and no entitlement is created. +pub async fn purchase_entitlement( + pool: &Pool, + club_id: &str, + cost: i64, + definition_id: &str, +) -> AppResult { + let mut tx = pool.begin().await?; + let balance = debit(&mut tx, club_id, cost).await?; + let entitlement_id = grant_entitlement(&mut tx, club_id, definition_id).await?; + tx.commit().await?; + Ok(PurchaseReceipt { + balance, + entitlement_id, + }) +} + +/// 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. +pub async fn redeem_entitlement( + pool: &Pool, + club_id: &str, + entitlement_id: &str, + items: &[GrantedItem], +) -> AppResult { + let mut tx = pool.begin().await?; + let definition_id = consume_entitlement(&mut tx, club_id, entitlement_id).await?; + for item in items { + add_item(&mut tx, club_id, &item.item_id, &item.card_id).await?; + } + tx.commit().await?; + Ok(definition_id) +} + +/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item +/// is not owned by the club nothing is credited. +pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult { + let mut tx = pool.begin().await?; + remove_item(&mut tx, club_id, item_id).await?; + let balance = credit(&mut tx, club_id, price).await?; + tx.commit().await?; + Ok(balance) +} + +/// Credit a reward to a club's balance atomically. +pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult { + let mut tx = pool.begin().await?; + let balance = credit(&mut tx, club_id, amount).await?; + tx.commit().await?; + Ok(balance) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TS: &str = "2026-01-01T00:00:00Z"; + + /// In-memory pool with the real schema and one club (1000 coins) owning one + /// item (`item-x`). Mirrors the `squad` service test harness. + async fn fixture() -> Pool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + sqlx::query( + "INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind("prof") + .bind("prof") + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("profile"); + sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind("club") + .bind("prof") + .bind("club") + .bind(1000i64) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("club"); + sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)") + .bind("item-x") + .bind("club") + .bind("def-x") + .bind(TS) + .execute(&pool) + .await + .expect("owned card"); + pool + } + + async fn pack_count(pool: &Pool) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM packs") + .fetch_one(pool) + .await + .unwrap() + } + + async fn item_count(pool: &Pool, item_id: &str) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?") + .bind(item_id) + .fetch_one(pool) + .await + .unwrap() + } + + #[tokio::test] + async fn balance_reads_seeded_value() { + let pool = fixture().await; + assert_eq!(balance(&pool, "club").await.unwrap(), 1000); + assert!(matches!( + balance(&pool, "ghost").await, + Err(AppError::NotFound(_)) + )); + } + + #[tokio::test] + async fn purchase_debits_and_grants() { + let pool = fixture().await; + let receipt = purchase_entitlement(&pool, "club", 300, "def-pack") + .await + .unwrap(); + assert_eq!(receipt.balance, 700); + assert_eq!(balance(&pool, "club").await.unwrap(), 700); + assert_eq!(pack_count(&pool).await, 1); + } + + #[tokio::test] + async fn purchase_insufficient_funds_rolls_back() { + let pool = fixture().await; + let err = purchase_entitlement(&pool, "club", 5000, "def-pack") + .await + .unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + // Nothing debited, no entitlement created. + assert_eq!(balance(&pool, "club").await.unwrap(), 1000); + assert_eq!(pack_count(&pool).await, 0); + } + + #[tokio::test] + async fn negative_amount_is_rejected() { + let pool = fixture().await; + assert!(matches!( + purchase_entitlement(&pool, "club", -50, "def").await, + Err(AppError::BadRequest(_)) + )); + // sell with negative price hits the credit guard and rolls back the removal. + assert!(matches!( + sell_item(&pool, "club", "item-x", -1).await, + Err(AppError::BadRequest(_)) + )); + assert_eq!(item_count(&pool, "item-x").await, 1); + assert_eq!(balance(&pool, "club").await.unwrap(), 1000); + } + + #[tokio::test] + async fn redeem_consumes_once_and_adds_items() { + let pool = fixture().await; + let ent = purchase_entitlement(&pool, "club", 100, "def-pack") + .await + .unwrap() + .entitlement_id; + let items = vec![GrantedItem { + item_id: "item-a".into(), + card_id: "def-a".into(), + }]; + let def = redeem_entitlement(&pool, "club", &ent, &items) + .await + .unwrap(); + assert_eq!(def, "def-pack"); + assert_eq!(item_count(&pool, "item-a").await, 1); + // Second redeem of the same entitlement is rejected; inventory unchanged. + let err = redeem_entitlement(&pool, "club", &ent, &items) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Conflict(_))); + assert_eq!(item_count(&pool, "item-a").await, 1); + } + + #[tokio::test] + async fn redeem_missing_entitlement_is_not_found() { + let pool = fixture().await; + assert!(matches!( + redeem_entitlement(&pool, "club", "no-such", &[]).await, + Err(AppError::NotFound(_)) + )); + } + + #[tokio::test] + async fn redeem_partial_failure_rolls_back() { + let pool = fixture().await; + let ent = purchase_entitlement(&pool, "club", 100, "def-pack") + .await + .unwrap() + .entitlement_id; + // Second item collides with the first instance id -> PK violation mid-loop. + let items = vec![ + GrantedItem { + item_id: "dup".into(), + card_id: "def-a".into(), + }, + GrantedItem { + item_id: "dup".into(), + card_id: "def-b".into(), + }, + ]; + let err = redeem_entitlement(&pool, "club", &ent, &items) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Database(_))); + // Whole redemption rolled back: entitlement still unconsumed, no items added. + assert_eq!(item_count(&pool, "dup").await, 0); + let def = redeem_entitlement( + &pool, + "club", + &ent, + &[GrantedItem { + item_id: "dup".into(), + card_id: "def-a".into(), + }], + ) + .await + .unwrap(); + assert_eq!(def, "def-pack"); + assert_eq!(item_count(&pool, "dup").await, 1); + } + + #[tokio::test] + async fn sell_removes_and_credits() { + let pool = fixture().await; + let new_balance = sell_item(&pool, "club", "item-x", 250).await.unwrap(); + assert_eq!(new_balance, 1250); + assert_eq!(item_count(&pool, "item-x").await, 0); + // Selling it again fails; balance is unchanged. + assert!(matches!( + sell_item(&pool, "club", "item-x", 250).await, + Err(AppError::NotFound(_)) + )); + assert_eq!(balance(&pool, "club").await.unwrap(), 1250); + } + + #[tokio::test] + async fn grant_reward_credits() { + let pool = fixture().await; + assert_eq!(grant_reward(&pool, "club", 500).await.unwrap(), 1500); + assert_eq!(balance(&pool, "club").await.unwrap(), 1500); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index de97ec3..ae16b60 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -3,6 +3,7 @@ pub mod card_db; pub mod checkin; pub mod club; pub mod draft; +pub mod economy; pub mod event; pub mod fut_champs; pub mod game_ext;