Guild bank: in-game commands, guild market listings and purchases, shop sales to the bank, guild payments

This commit is contained in:
Claude committed 2026-09-30 17:57:58 +00:00
1 parent 5defda1128
commit e6c6073eae
14 files changed
+680 -82

No files matched your search

+73 -58
View File
@@ -10,7 +10,7 @@ use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEcon
use serde::Deserialize;
use serde_json::Value;
async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
pub(crate) async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
let mut tx = state.db.begin().await?;
// The first statement obtains the write lock before reading any balances.
@@ -24,14 +24,14 @@ async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -
Ok((tx, previous))
}
async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
pub(crate) async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
tx.commit().await?;
Ok(Json(response))
}
async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> {
pub(crate) async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> {
sqlx::query("INSERT INTO server_economy(server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, 1000, ?) ON CONFLICT(server_id, uuid) DO NOTHING")
.bind(server_id).bind(uuid).bind(name).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?;
Ok(())
@@ -61,9 +61,11 @@ pub async fn server_adjust_balance(GameServer(server): GameServer, State(state):
}
pub async fn server_market_read(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>)> = sqlx::query_as("SELECT id, seller_name, item_id, item_name, amount, price, item_data FROM server_market WHERE server_id = ? ORDER BY id DESC LIMIT 45")
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT m.id, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.item_data, g.tag
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id WHERE m.server_id = ? ORDER BY m.id DESC LIMIT 45")
.bind(server.id).fetch_all(&state.db).await?;
Ok(Json(rows.into_iter().map(|(id, seller, item_id, item_name, amount, price, data)| serde_json::json!({"id":id,"seller_name":seller,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect()))
Ok(Json(rows.into_iter().map(|(id, seller, item_id, item_name, amount, price, data, guild)| serde_json::json!({"id":id,"seller_name":seller,"seller_guild":guild,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect()))
}
// ---------------------------------------------------------------------------
@@ -171,11 +173,11 @@ pub async fn get_server_market(
Path(server_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<MarketListing>>> {
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
FROM server_market
WHERE server_id = ?
ORDER BY id DESC
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String, Option<String>)> = sqlx::query_as(
"SELECT m.id, m.server_id, m.seller_uuid, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.created_at, g.tag
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id
WHERE m.server_id = ?
ORDER BY m.id DESC
LIMIT 100",
)
.bind(server_id)
@@ -184,7 +186,8 @@ pub async fn get_server_market(
let list = rows
.into_iter()
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created, guild_tag)| MarketListing {
seller_guild_tag: guild_tag,
id,
server_id: sid,
seller_uuid: suuid,
@@ -411,6 +414,9 @@ pub struct MarketListPayload {
pub item_name: String,
pub amount: i32,
pub price: f64,
/// Sell on behalf of the seller's guild: the sale is paid into its bank.
#[serde(default)]
pub as_guild: bool,
}
pub async fn server_market_list(
@@ -422,9 +428,14 @@ pub async fn server_market_list(
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
let now = chrono::Utc::now().to_rfc3339();
let guild_id = if payload.as_guild {
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.seller_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can sell for the guild")); }
Some(m.guild_id)
} else { None };
let id: i64 = sqlx::query_scalar(
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data, seller_guild_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(server.id)
@@ -436,6 +447,7 @@ pub async fn server_market_list(
.bind(payload.price)
.bind(&now)
.bind(&payload.item_data)
.bind(&guild_id)
.fetch_one(&mut *tx)
.await?;
@@ -448,6 +460,9 @@ pub struct MarketBuyPayload {
pub listing_id: i64,
pub buyer_uuid: String,
pub buyer_name: String,
/// Pay from the buyer's guild bank (leaders and officers) instead of their own balance.
#[serde(default)]
pub as_guild: bool,
}
pub async fn server_market_buy(
@@ -457,10 +472,10 @@ pub async fn server_market_buy(
) -> AppResult<Json<Value>> {
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
let listing: Option<(String, String, String, String, i32, f64, Option<String>)> = sqlx::query_as(
let listing: Option<(String, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
"DELETE FROM server_market
WHERE id = ? AND server_id = ?
RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data
RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data, seller_guild_id
",
)
.bind(payload.listing_id)
@@ -468,52 +483,52 @@ pub async fn server_market_buy(
.fetch_optional(&mut *tx)
.await?;
let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data)) = listing else {
let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data, seller_guild)) = listing else {
return Err(AppError::not_found("Listing not found"));
};
// Guild sales only pay the guild if it still exists; otherwise the lister is paid.
let seller_guild: Option<(String, String)> = match seller_guild {
Some(id) => sqlx::query_as("SELECT id, tag FROM guilds WHERE id = ?").bind(id).fetch_optional(&mut *tx).await?,
None => None,
};
let buyer_guild = if payload.as_guild {
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.buyer_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can spend guild money")); }
Some(m)
} else { None };
ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?;
let now = chrono::Utc::now().to_rfc3339();
// Deduct buyer
let buyer_bal: f64 = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.buyer_uuid)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
// Buyer pays: their own balance, or their guild's bank.
let new_balance = if let Some(m) = &buyer_guild {
let left = crate::routes::guild_bank::adjust_wallet(&mut tx, server.id, &m.guild_id, -price).await?;
crate::routes::guild_bank::log(&mut tx, server.id, &m.guild_id, &payload.buyer_uuid, "purchase", price, &format!("Market: {amount}x {item_name}")).await?;
left
} else {
let buyer_bal: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(server.id).bind(&payload.buyer_uuid).fetch_optional(&mut *tx).await?.unwrap_or(0.0);
if buyer_bal < price {
return Err(AppError::bad_request("Insufficient funds"));
}
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(price).bind(&now).bind(server.id).bind(&payload.buyer_uuid).execute(&mut *tx).await?;
buyer_bal - price
};
if buyer_bal < price {
return Err(AppError::bad_request("Insufficient funds"));
}
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(price)
.bind(&now)
.bind(server.id)
.bind(&payload.buyer_uuid)
.execute(&mut *tx)
.await?;
let new_balance = buyer_bal - price;
// Pay seller
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
balance = balance + excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&seller_uuid)
.bind(&seller_name)
.bind(price)
.bind(&now)
.execute(&mut *tx)
.await?;
// Seller is paid: the guild bank for guild listings, otherwise the player.
let (to_uuid, to_name) = if let Some((guild_id, tag)) = &seller_guild {
crate::routes::guild_bank::adjust_wallet(&mut tx, server.id, guild_id, price).await?;
crate::routes::guild_bank::log(&mut tx, server.id, guild_id, &seller_uuid, "sale", price, &format!("Market: {amount}x {item_name}")).await?;
(format!("guild:{guild_id}"), format!("[{tag}] guild bank"))
} else {
// A seller without an account first gets the usual starting balance, then the sale on top.
ensure_balance(&mut tx, server.id, &seller_uuid, &seller_name).await?;
sqlx::query("UPDATE server_economy SET balance = balance + ?, username = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(price).bind(&seller_name).bind(&now).bind(server.id).bind(&seller_uuid)
.execute(&mut *tx).await?;
(seller_uuid.clone(), seller_name.clone())
};
// Delete listing
sqlx::query("DELETE FROM server_market WHERE id = ?")
@@ -528,10 +543,10 @@ pub async fn server_market_buy(
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(server.id)
.bind(&payload.buyer_uuid)
.bind(&payload.buyer_name)
.bind(&seller_uuid)
.bind(&seller_name)
.bind(match &buyer_guild { Some(m) => format!("guild:{}", m.guild_id), None => payload.buyer_uuid.clone() })
.bind(match &buyer_guild { Some(m) => format!("[{}] guild bank", m.tag), None => payload.buyer_name.clone() })
.bind(&to_uuid)
.bind(&to_name)
.bind(price)
.bind(&desc)
.bind(&now)
+233
View File
@@ -0,0 +1,233 @@
//! The guild bank as seen from the game server: check the balance, move
//! money between a player and the guild, credit shop sales to the guild, and
//! pay other guilds. Every mutating call is idempotent on `operation_id`.
use crate::error::{AppError, AppResult};
use crate::routes::economy::{begin_operation, ensure_balance, finish_operation};
use crate::routes::servers::{GameServer, ServerRow};
use crate::state::AppState;
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::SqliteConnection;
/// A player's place in the guild that plays on this server's instance.
pub struct Membership {
pub guild_id: String,
pub name: String,
pub tag: String,
pub role: String,
}
impl Membership {
pub fn can_spend(&self) -> bool {
matches!(self.role.as_str(), "leader" | "officer")
}
}
pub async fn membership(conn: &mut SqliteConnection, server: &ServerRow, uuid: &str) -> AppResult<Option<Membership>> {
let row: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT g.id, g.name, g.tag, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id
WHERE gm.uuid = ? AND g.instance_id = ?",
)
.bind(uuid)
.bind(&server.instance_id)
.fetch_optional(&mut *conn)
.await?;
Ok(row.map(|(guild_id, name, tag, role)| Membership { guild_id, name, tag, role }))
}
pub fn valid_amount(amount: f64) -> AppResult<()> {
if !amount.is_finite() || !(0.01..=1e9).contains(&amount) || (amount * 100.0 - (amount * 100.0).round()).abs() > 1e-6 {
return Err(AppError::bad_request("Amount must be between 0.01 and 1,000,000,000 with at most two decimals"));
}
Ok(())
}
pub async fn ensure_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id: &str) -> AppResult<()> {
sqlx::query("INSERT INTO guild_wallets(server_id, guild_id, balance, updated_at) VALUES (?, ?, 0, ?) ON CONFLICT(server_id, guild_id) DO NOTHING")
.bind(server_id)
.bind(guild_id)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&mut *conn)
.await?;
Ok(())
}
/// Move `delta` into (positive) or out of (negative) a guild wallet. Fails if it would go below zero.
pub async fn adjust_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, delta: f64) -> AppResult<f64> {
ensure_wallet(conn, server_id, guild_id).await?;
let balance: Option<f64> = sqlx::query_scalar(
"UPDATE guild_wallets SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND guild_id = ? AND balance + ? >= 0 RETURNING balance",
)
.bind(delta)
.bind(chrono::Utc::now().to_rfc3339())
.bind(server_id)
.bind(guild_id)
.bind(delta)
.fetch_optional(&mut *conn)
.await?;
balance.ok_or_else(|| AppError::bad_request("The guild bank doesn't have enough funds"))
}
pub async fn log(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, actor: &str, kind: &str, amount: f64, note: &str) -> AppResult<()> {
sqlx::query("INSERT INTO guild_wallet_transactions(server_id, guild_id, actor_uuid, kind, amount, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(server_id)
.bind(guild_id)
.bind(actor)
.bind(kind)
.bind(amount)
.bind(note.chars().filter(|c| !c.is_control()).take(120).collect::<String>())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&mut *conn)
.await?;
Ok(())
}
async fn player_balance(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<f64> {
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0.0))
}
#[derive(Deserialize)]
pub struct Who {
pub uuid: String,
}
/// Bank summary for `/guild bank`: who the player's guild is, their role, the balance and recent activity.
pub async fn server_bank_info(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
let mut conn = state.db.acquire().await?;
let Some(m) = membership(&mut conn, &server, &p.uuid).await? else {
return Ok(Json(json!({ "guild": null })));
};
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE server_id = ? AND guild_id = ?")
.bind(server.id)
.bind(&m.guild_id)
.fetch_optional(&mut *conn)
.await?
.unwrap_or(0.0);
let recent: Vec<(String, String, f64, String, String)> = sqlx::query_as(
"SELECT t.kind, COALESCE((SELECT gm.name FROM guild_members gm WHERE gm.guild_id = t.guild_id AND gm.uuid = t.actor_uuid), 'Former member'), t.amount, t.note, t.created_at
FROM guild_wallet_transactions t WHERE t.server_id = ? AND t.guild_id = ? ORDER BY t.id DESC LIMIT 8",
)
.bind(server.id)
.bind(&m.guild_id)
.fetch_all(&mut *conn)
.await?;
Ok(Json(json!({
"guild": { "id": m.guild_id, "name": m.name, "tag": m.tag },
"role": m.role,
"can_spend": m.can_spend(),
"balance": balance,
"my_balance": player_balance(&mut conn, server.id, &p.uuid).await?,
"recent": recent.into_iter().map(|(kind, who, amount, note, at)| json!({ "kind": kind, "who": who, "amount": amount, "note": note, "at": at })).collect::<Vec<_>>(),
})))
}
#[derive(Deserialize)]
pub struct Transfer {
pub operation_id: String,
pub uuid: String,
pub username: String,
pub amount: f64,
/// `deposit` (player → guild) or `withdraw` (guild → player, officers and leaders).
pub action: String,
}
pub async fn server_bank_transfer(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Transfer>) -> AppResult<Json<Value>> {
valid_amount(p.amount)?;
let withdraw = match p.action.as_str() {
"deposit" => false,
"withdraw" => true,
_ => return Err(AppError::bad_request("action must be deposit or withdraw")),
};
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous {
return Ok(Json(previous));
}
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if withdraw && !m.can_spend() {
return Err(AppError::forbidden("Only guild leaders and officers can withdraw"));
}
ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?;
let (guild_delta, player_delta) = if withdraw { (-p.amount, p.amount) } else { (p.amount, -p.amount) };
let moved: Option<f64> = sqlx::query_scalar(
"UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance",
)
.bind(player_delta)
.bind(chrono::Utc::now().to_rfc3339())
.bind(server.id)
.bind(&p.uuid)
.bind(player_delta)
.fetch_optional(&mut *tx)
.await?;
let my_balance = moved.ok_or_else(|| AppError::bad_request("Insufficient funds"))?;
let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, guild_delta).await?;
log(&mut tx, server.id, &m.guild_id, &p.uuid, &p.action, p.amount, "").await?;
let message = if withdraw { format!("Withdrew ${:.2} from [{}] bank.", p.amount, m.tag) } else { format!("Deposited ${:.2} into [{}] bank.", p.amount, m.tag) };
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "my_balance": my_balance, "message": message })).await
}
#[derive(Deserialize)]
pub struct Credit {
pub operation_id: String,
pub uuid: String,
pub username: String,
pub amount: f64,
#[serde(default)]
pub description: String,
}
/// Shop sales made with `/guild sell`: the proceeds go to the guild bank instead of the seller.
pub async fn server_bank_credit(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Credit>) -> AppResult<Json<Value>> {
valid_amount(p.amount)?;
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous {
return Ok(Json(previous));
}
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, p.amount).await?;
let note = if p.description.is_empty() { "Items sold to the server shop" } else { &p.description };
log(&mut tx, server.id, &m.guild_id, &p.uuid, "sale", p.amount, note).await?;
let message = format!("Sold for ${:.2}, paid into the [{}] bank.", p.amount, m.tag);
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await
}
#[derive(Deserialize)]
pub struct Pay {
pub operation_id: String,
pub uuid: String,
/// Tag of the receiving guild.
pub to_tag: String,
pub amount: f64,
#[serde(default)]
pub note: String,
}
/// Guild-to-guild payment (leaders and officers).
pub async fn server_bank_pay(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Pay>) -> AppResult<Json<Value>> {
valid_amount(p.amount)?;
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous {
return Ok(Json(previous));
}
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() {
return Err(AppError::forbidden("Only guild leaders and officers can spend guild money"));
}
let target: Option<(String, String, String)> =
sqlx::query_as("SELECT id, name, tag FROM guilds WHERE instance_id = ? AND tag = ? COLLATE NOCASE AND id <> ?")
.bind(&server.instance_id)
.bind(p.to_tag.trim())
.bind(&m.guild_id)
.fetch_optional(&mut *tx)
.await?;
let (target_id, target_name, target_tag) = target.ok_or_else(|| AppError::not_found("No other guild with that tag"))?;
let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, -p.amount).await?;
adjust_wallet(&mut tx, server.id, &target_id, p.amount).await?;
let note = p.note.chars().take(60).collect::<String>();
log(&mut tx, server.id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}")).await?;
log(&mut tx, server.id, &target_id, &p.uuid, "transfer_in", p.amount, &format!("from [{}] {} {note}", m.tag, m.name)).await?;
let message = format!("Paid ${:.2} from [{}] to [{}] {}.", p.amount, m.tag, target_tag, target_name);
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await
}
+2 -2
View File
@@ -150,14 +150,14 @@ pub async fn guild_wallet(
if !member { return Err(AppError::forbidden("Guild membership is required")); }
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id=? AND server_id=?")
.bind(&guild_id).bind(query.server_id).fetch_optional(&state.db).await?.unwrap_or(0.0);
let rows: Vec<(i64, String, String, f64, String)> = sqlx::query_as("SELECT id,actor_uuid,kind,amount,created_at FROM guild_wallet_transactions WHERE guild_id=? AND server_id=? ORDER BY id DESC LIMIT 30")
let rows: Vec<(i64, String, String, f64, String, String)> = sqlx::query_as("SELECT id,actor_uuid,kind,amount,created_at,note FROM guild_wallet_transactions WHERE guild_id=? AND server_id=? ORDER BY id DESC LIMIT 30")
.bind(&guild_id).bind(query.server_id).fetch_all(&state.db).await?;
let role: String = sqlx::query_scalar("SELECT gm.role FROM guild_members gm WHERE gm.guild_id=? AND gm.uuid=?")
.bind(&guild_id).bind(&auth.uuid).fetch_one(&state.db).await?;
// A player's first transfer creates their balance at 1000, so show that until then.
let my_balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id=? AND uuid=?")
.bind(query.server_id).bind(&auth.uuid).fetch_optional(&state.db).await?.unwrap_or(1000.0);
Ok(Json(serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at})).collect::<Vec<_>>()})))
Ok(Json(serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at,note)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at,"note":note})).collect::<Vec<_>>()})))
}
pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
+5
View File
@@ -4,6 +4,7 @@ pub mod activity;
pub mod admin;
pub mod connections;
pub mod economy;
pub mod guild_bank;
pub mod guilds;
pub mod landing;
pub mod livemap;
@@ -181,6 +182,10 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/player", post(guilds::server_get_player_guild))
.route("/guilds/create", post(guilds::server_create_guild))
.route("/guilds/leave", post(guilds::server_guild_leave))
.route("/guilds/bank", post(guild_bank::server_bank_info))
.route("/guilds/bank/transfer", post(guild_bank::server_bank_transfer))
.route("/guilds/bank/credit", post(guild_bank::server_bank_credit))
.route("/guilds/bank/pay", post(guild_bank::server_bank_pay))
.route("/economy/balance", post(economy::server_get_balance))
.route("/economy/pay", post(economy::server_transfer))
.route("/economy/transfer", post(economy::server_transfer))