bcc4f5104a
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.
573 lines
19 KiB
Rust
573 lines
19 KiB
Rust
//! 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}")))
|
|
}
|
|
|
|
/// 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<Vec<Entitlement>> {
|
|
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<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,
|
|
})
|
|
}
|
|
|
|
/// 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<i64> {
|
|
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<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
|
|
/// 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);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|