Files
OpenFUT-Core/src/routes/economy.rs
T
OpenFUT Agent bcc4f5104a feat(economy): purchase_items primitive + entitlement import seeding
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.
2026-08-13 19:27:06 +00:00

164 lines
5.1 KiB
Rust

//! 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 }))
}
#[derive(Deserialize)]
pub struct PurchaseItemsRequest {
pub cost: i64,
pub items: Vec<GrantedItem>,
}
/// `POST /economy/purchase-items` — atomic debit + mint several items.
pub async fn post_purchase_items(
State(state): State<AppState>,
game: GameId,
Json(req): Json<PurchaseItemsRequest>,
) -> AppResult<Json<BalanceResponse>> {
let club = resolve_club(&state, &game).await?;
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
Ok(Json(BalanceResponse { balance }))
}