feat(economy): expose transactional economy service over HTTP
Add generic /economy/* routes (balance, entitlements, purchase-entitlement, redeem-entitlement, sell-item, grant-reward, purchase-item) resolving the club server-side via the same game-scoped active-profile mechanism as /collection — callers never supply a club id, so there is no cross-club access. Add list_unopened_entitlements + Entitlement for the reader side. Game-neutral: no currency names or wire semantics. 4 endpoint integration tests (balance+reward, purchase+redeem, purchase-item+sell fail-closed, insufficient-funds fail-closed); full matrix 43 lib + 115 integration green.
This commit is contained in:
+22
@@ -172,6 +172,28 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/cards", get(routes::cards::get_cards))
|
.route("/cards", get(routes::cards::get_cards))
|
||||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||||
.route("/collection", get(routes::cards::get_collection))
|
.route("/collection", get(routes::cards::get_collection))
|
||||||
|
.route("/economy/balance", get(routes::economy::get_balance))
|
||||||
|
.route(
|
||||||
|
"/economy/entitlements",
|
||||||
|
get(routes::economy::get_entitlements),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-entitlement",
|
||||||
|
post(routes::economy::post_purchase_entitlement),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/redeem-entitlement",
|
||||||
|
post(routes::economy::post_redeem_entitlement),
|
||||||
|
)
|
||||||
|
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||||
|
.route(
|
||||||
|
"/economy/grant-reward",
|
||||||
|
post(routes::economy::post_grant_reward),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-item",
|
||||||
|
post(routes::economy::post_purchase_item),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/collection/:owned_card_id",
|
"/collection/:owned_card_id",
|
||||||
delete(routes::cards::delete_owned_card),
|
delete(routes::cards::delete_owned_card),
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! Generic economy HTTP boundary.
|
||||||
|
//!
|
||||||
|
//! Exposes [`crate::services::economy`] over the same game-scoped active-profile
|
||||||
|
//! resolution every other Core route uses ([`GameId`] header → active profile →
|
||||||
|
//! club). The caller (a game host) never supplies a club id; Core maps the game
|
||||||
|
//! to its authoritative club, so there is no cross-club economy access. Every
|
||||||
|
//! op is a single durable SQLite transaction in the service layer.
|
||||||
|
//!
|
||||||
|
//! This surface is deliberately game-neutral: no currency names, pack ids, or
|
||||||
|
//! wire semantics — those live in the game host/adapter.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
extractors::GameId,
|
||||||
|
services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve the game-scoped active profile's club id.
|
||||||
|
async fn resolve_club(state: &AppState, game: &GameId) -> AppResult<String> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
Ok(club.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BalanceResponse {
|
||||||
|
pub balance: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /economy/balance` — the club's currency balance.
|
||||||
|
pub async fn get_balance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::balance(&state.pool, &club).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /economy/entitlements` — the club's unconsumed entitlements.
|
||||||
|
pub async fn get_entitlements(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<Vec<economy::Entitlement>>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
Ok(Json(
|
||||||
|
economy::list_unopened_entitlements(&state.pool, &club).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseEntitlementRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-entitlement` — atomic debit + grant.
|
||||||
|
pub async fn post_purchase_entitlement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseEntitlementRequest>,
|
||||||
|
) -> AppResult<Json<economy::PurchaseReceipt>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
Ok(Json(
|
||||||
|
economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RedeemEntitlementRequest {
|
||||||
|
pub entitlement_id: String,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RedeemEntitlementResponse {
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/redeem-entitlement` — atomic consume-once + add items.
|
||||||
|
pub async fn post_redeem_entitlement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<RedeemEntitlementRequest>,
|
||||||
|
) -> AppResult<Json<RedeemEntitlementResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let definition_id =
|
||||||
|
economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?;
|
||||||
|
Ok(Json(RedeemEntitlementResponse { definition_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SellItemRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub price: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/sell-item` — atomic remove + credit.
|
||||||
|
pub async fn post_sell_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<SellItemRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GrantRewardRequest {
|
||||||
|
pub amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/grant-reward` — atomic credit.
|
||||||
|
pub async fn post_grant_reward(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<GrantRewardRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::grant_reward(&state.pool, &club, req.amount).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub item_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-item` — atomic debit + mint item.
|
||||||
|
pub async fn post_purchase_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance =
|
||||||
|
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod cards;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod economy;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|||||||
@@ -194,6 +194,27 @@ pub async fn balance(pool: &Pool, club_id: &str) -> AppResult<i64> {
|
|||||||
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
|
.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
|
/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club
|
||||||
/// cannot afford `cost`, nothing is debited and no entitlement is created.
|
/// cannot afford `cost`, nothing is debited and no entitlement is created.
|
||||||
pub async fn purchase_entitlement(
|
pub async fn purchase_entitlement(
|
||||||
|
|||||||
+594
-178
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user