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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user