using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using DorkNet.Server.Auth; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Store; /// /// api.rec.net/api/purchase/v1/* — real purchase flow that verifies /// the player has enough currency, deducts it, and grants the item /// to their inventory. Replaces the old "ack-everything" stubs in /// AllEndpointsController that let the watch think it bought /// something but never actually persisted the transaction. /// /// The 2020 client follows a three-step flow when the player taps /// "buy" on a Shop item: /// 1. POST initiatepurchase — server validates the item exists + /// player has funds, returns a transaction id (we just echo /// back the item id; no real reservation needed for a private /// server's threat model). /// 2. POST processpurchase — server actually deducts currency, /// grants the item to the avatar's InventoryJson list, returns /// the new balance. /// 3. POST completepurchase — final ack; client uses this to /// flip its local UI state from "purchasing…" to "owned". /// We keep all three so the watch's flow doesn't deadlock waiting /// for one or another, but the actual mutation happens in /// processpurchase. initiate and complete just validate + ack. /// [ApiController] [Authorize] public class PurchaseController(StoreService store, ILogger logger) : ControllerBase { public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity); /// POST initiatepurchase — validate the item exists and /// the caller has enough currency, but don't mutate yet. Returns /// the resolved item id so the watch can echo it back on /// processpurchase. [HttpPost("api/purchase/v1/initiatepurchase")] [HttpPost("api/purchase/v2/initiatepurchase")] [HttpPost("api/purchase/v3/initiatepurchase")] public async Task Initiate([FromForm] PurchaseRequest body) { var item = await ResolveItemAsync(body); if (item is null) return Ok(new { success = false, error = "item_not_found" }); return Ok(new { success = true, error = "", ItemId = item.Id, Slug = item.Slug, // Hand back a token the watch may include in // processpurchase. We accept anything on the way in, so // this is informational. TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}", }); } /// POST processpurchase — atomic charge + grant. The /// real work. Returns success+balance on completion or the /// specific error code on failure (insufficient_funds, /// item_not_available, etc.) so the watch can show the right UI /// message. [HttpPost("api/purchase/v1/processpurchase")] [HttpPost("api/purchase/v2/processpurchase")] [HttpPost("api/purchase/v3/processpurchase")] public async Task Process([FromForm] PurchaseRequest body) { var pid = this.RequireCurrentPlayerId(); var item = await ResolveItemAsync(body); if (item is null) return Ok(new { success = false, error = "item_not_found" }); var result = await store.PurchaseAsync(pid, item.Id); logger.LogInformation( "[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}", pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0); return Ok(new { success = result.Success, error = result.Error ?? "", Balance = result.Balance ?? 0, CurrencyType = item.CurrencyType, ItemId = item.Id, Slug = result.Slug ?? item.Slug, }); } /// POST completepurchase — final ack. Client flips its /// UI to "owned" state and refreshes the avatar selector. [HttpPost("api/purchase/v1/completepurchase")] [HttpPost("api/purchase/v2/completepurchase")] [HttpPost("api/purchase/v3/completepurchase")] public IActionResult Complete() => Ok(new { success = true, error = "" }); /// POST cancelpurchase — client retreated mid-flow /// (closed the panel, network blip, etc.). Nothing to roll back /// since we don't reserve in initiate. [HttpPost("api/purchase/v1/cancelpurchase")] [HttpPost("api/purchase/v2/cancelpurchase")] [HttpPost("api/purchase/v3/cancelpurchase")] public IActionResult Cancel() => Ok(new { success = true, error = "" }); /// POST cleanuppending — client startup probe to release /// any stale "in-flight" purchases from a crashed previous /// session. We don't track in-flight state, so always-ack is /// correct. [HttpPost("api/purchase/v1/cleanuppending")] [HttpPost("api/purchase/v2/cleanuppending")] [HttpPost("api/purchase/v3/cleanuppending")] public IActionResult CleanupPending() => Ok(new { success = true, error = "" }); /// POST subscription/v1/* — Rec Room+. The private /// server has no real billing integration, so buying RR+ is a yes/ack /// operation and cancel is also acknowledged. Returning an active /// subscription object keeps the client out of platform IAP UI. [HttpPost("/subscription/v1")] [HttpPost("/subscription/v1/purchase")] [HttpPost("/subscription/v1/buy")] [HttpPost("/subscription/v1/cancel")] [HttpPost("/api/subscription/v1")] [HttpPost("/api/subscription/v1/purchase")] [HttpPost("/api/subscription/v1/buy")] [HttpPost("/api/subscription/v1/cancel")] public IActionResult SubscriptionAck() { var pid = this.CurrentPlayerId() ?? 0; logger.LogInformation("[subscription] ack requested by player {Pid}", pid); return Ok(SubscriptionResponse(pid)); } [HttpGet("/subscription/v1")] [HttpGet("/subscription/v1/current")] [HttpGet("/subscription/v1/mine")] [HttpGet("/api/subscription/v1")] [HttpGet("/api/subscription/v1/current")] [HttpGet("/api/subscription/v1/mine")] public IActionResult SubscriptionStatus() => Ok(SubscriptionResponse(this.CurrentPlayerId() ?? 0)); private static Dictionary SubscriptionResponse(long playerId) { var now = DateTime.UtcNow; var pascal = new Dictionary { ["SubscriptionId"] = $"dorknet-rrplus-{playerId}", ["RecNetPlayerId"] = playerId, ["IsActive"] = true, ["StartedAt"] = now, ["CurrentPeriodEnd"] = now.AddYears(10), ["RenewalDate"] = now.AddYears(10), }; var camel = new Dictionary { ["subscriptionId"] = $"dorknet-rrplus-{playerId}", ["recNetPlayerId"] = playerId, ["isActive"] = true, ["startedAt"] = now, ["currentPeriodEnd"] = now.AddYears(10), ["renewalDate"] = now.AddYears(10), }; return new Dictionary { ["success"] = true, ["error"] = "", ["IsSubscribed"] = true, ["isSubscribed"] = true, ["Active"] = true, ["active"] = true, ["Subscription"] = pascal, ["subscription"] = camel, }; } private async Task ResolveItemAsync(PurchaseRequest body) { if (body.ItemId is long id && id > 0) return await store.GetByIdAsync(id); if (!string.IsNullOrWhiteSpace(body.Slug)) return await store.GetBySlugAsync(body.Slug); return null; } }