//! 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}"))) } /// One unopened entitlement a club owns. #[derive(Debug, Clone, Serialize)] pub struct Entitlement { pub id: String, pub definition_id: String, } /// List a club's unconsumed entitlements (opened = 0), oldest first. pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult> { let rows = sqlx::query_as::<_, (String, String)>( "SELECT id, definition_id FROM packs WHERE club_id = ? AND opened = 0 ORDER BY created_at ASC, id ASC", ) .bind(club_id) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(id, definition_id)| Entitlement { id, definition_id }) .collect()) } /// 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, }) } /// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club /// cannot afford `cost`, nothing is debited and no item is added. This is the /// "buy a specific item" primitive (a debit paired with an inventory add), for /// synthetic-seller markets where the purchased item is minted rather than /// transferred from another owner. Returns the post-debit balance. pub async fn purchase_item( pool: &Pool, club_id: &str, cost: i64, item_id: &str, card_id: &str, ) -> AppResult { let mut tx = pool.begin().await?; let balance = debit(&mut tx, club_id, cost).await?; add_item(&mut tx, club_id, item_id, card_id).await?; tx.commit().await?; 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. 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); } #[tokio::test] async fn purchase_item_debits_and_mints() { let pool = fixture().await; let bal = purchase_item(&pool, "club", 400, "item-new", "def-new") .await .unwrap(); assert_eq!(bal, 600); assert_eq!(balance(&pool, "club").await.unwrap(), 600); assert_eq!(item_count(&pool, "item-new").await, 1); } #[tokio::test] async fn purchase_item_insufficient_funds_rolls_back() { let pool = fixture().await; let err = purchase_item(&pool, "club", 9000, "item-new", "def-new") .await .unwrap_err(); assert!(matches!(err, AppError::BadRequest(_))); // Nothing debited, no item minted. 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); } }