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.
This commit is contained in:
OpenFUT Agent
2026-08-13 18:44:51 +00:00
parent 66c88fb48e
commit ee2caa0bb0
2 changed files with 456 additions and 0 deletions
+455
View File
@@ -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<i64> {
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<i64> {
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<i64> {
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<String> {
// 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<String> {
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<String> {
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<i64> {
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<PurchaseReceipt> {
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<String> {
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<i64> {
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<i64> {
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);
}
}
+1
View File
@@ -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;