Add ZarinPal sandbox payments for buying coins (config-driven merchant)

- ZarinpalService (request/verify) + /api/coins/pay/request (JWT) and
  /api/coins/pay/callback (verify → credit via ProfileService.BuyCoins → redirect
  back with ?pay=success); merchant id from config (sandbox default)
- Client buyCoins (live) returns the StartPay redirect URL; BuyCoinsScreen
  redirects; page.tsx handles the ?pay return (notify + refresh)
- Verified: sandbox request returns a real StartPay URL
- Documented Cafe Bazaar (Poolakey) / Myket IAB as the required store payment path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 17:59:30 +03:30
parent 4f2e4e14ea
commit cfed2950b2
8 changed files with 171 additions and 5 deletions
@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.Json;
namespace Hokm.Server.Payments;
public sealed class ZarinpalOptions
{
public string MerchantId { get; set; } = "";
public bool Sandbox { get; set; } = true;
/// <summary>Server URL ZarinPal redirects back to after payment.</summary>
public string CallbackUrl { get; set; } = "http://localhost:5005/api/coins/pay/callback";
/// <summary>Where to send the user (the web app) after we finish.</summary>
public string ClientReturnUrl { get; set; } = "http://localhost:3000";
}
public sealed record PendingPayment(string UserId, string PackId, int AmountRial);
/// <summary>
/// ZarinPal (sandbox) gateway for buying coins. Cafe Bazaar / Myket builds use
/// their in-app billing instead (see ANDROID/README) — this is the web/PWA path.
/// </summary>
public sealed class ZarinpalService
{
private static readonly HttpClient Http = new();
private readonly ZarinpalOptions _opts;
private readonly ConcurrentDictionary<string, PendingPayment> _pending = new();
public ZarinpalService(ZarinpalOptions opts) => _opts = opts;
public string ClientReturnUrl => _opts.ClientReturnUrl;
private string Base => _opts.Sandbox
? "https://sandbox.zarinpal.com"
: "https://payment.zarinpal.com";
/// <summary>Create a payment and return the StartPay URL to redirect the user to.</summary>
public async Task<string?> Request(string userId, string packId, int priceToman, string description)
{
int amountRial = priceToman * 10;
var body = new
{
merchant_id = _opts.MerchantId,
amount = amountRial,
callback_url = _opts.CallbackUrl,
description,
};
try
{
var resp = await Http.PostAsJsonAsync($"{Base}/pg/v4/payment/request.json", body);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var data = doc.RootElement.GetProperty("data");
if (data.ValueKind == JsonValueKind.Object &&
data.TryGetProperty("code", out var code) && code.GetInt32() == 100 &&
data.TryGetProperty("authority", out var auth))
{
var authority = auth.GetString()!;
_pending[authority] = new PendingPayment(userId, packId, amountRial);
return $"{Base}/pg/StartPay/{authority}";
}
}
catch { /* gateway unreachable */ }
return null;
}
/// <summary>Verify a returned payment. Returns the pending row on success.</summary>
public async Task<PendingPayment?> Verify(string authority, string status)
{
if (!_pending.TryGetValue(authority, out var pending)) return null;
if (!string.Equals(status, "OK", StringComparison.OrdinalIgnoreCase)) return null;
var body = new { merchant_id = _opts.MerchantId, amount = pending.AmountRial, authority };
try
{
var resp = await Http.PostAsJsonAsync($"{Base}/pg/v4/payment/verify.json", body);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var data = doc.RootElement.GetProperty("data");
if (data.ValueKind == JsonValueKind.Object &&
data.TryGetProperty("code", out var code) &&
(code.GetInt32() == 100 || code.GetInt32() == 101))
{
_pending.TryRemove(authority, out _);
return pending;
}
}
catch { /* gateway unreachable */ }
return null;
}
}
+28
View File
@@ -5,6 +5,7 @@ using Hokm.Server.Auth;
using Hokm.Server.Data;
using Hokm.Server.Game;
using Hokm.Server.Hubs;
using Hokm.Server.Payments;
using Hokm.Server.Profiles;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
@@ -32,6 +33,12 @@ builder.Services.AddDbContext<AppDbContext>(o =>
});
builder.Services.AddScoped<ProfileService>();
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4dfc-98e2-d4af5d81528d";
builder.Services.AddSingleton(zp);
builder.Services.AddSingleton<ZarinpalService>();
// --- SignalR (camelCase to match the TS client) ---
builder.Services
.AddSignalR()
@@ -144,6 +151,27 @@ app.MapPost("/api/match/result", async (ClaimsPrincipal u, ProfileService svc, M
return Results.Json(new { reward, profile = p }, JsonOpts.Default);
}).RequireAuthorization();
// ZarinPal: create a payment → returns the StartPay URL to redirect to.
app.MapPost("/api/coins/pay/request", async (ClaimsPrincipal u, ZarinpalService zp, BuyReq req) =>
{
var pack = ProfileService.Packs.FirstOrDefault(p => p.Id == req.PackId);
if (pack == null) return Results.BadRequest(new { ok = false });
var url = await zp.Request(Uid(u), pack.Id, pack.PriceToman, $"خرید {pack.Coins + pack.Bonus} سکه برگ وسط");
return url != null ? Results.Json(new { ok = true, url }) : Results.Json(new { ok = false });
}).RequireAuthorization();
// ZarinPal redirects the browser here after payment (no JWT — authority is the secret).
app.MapGet("/api/coins/pay/callback", async (string? authority, string? status, ZarinpalService zp, ProfileService svc) =>
{
var pending = authority != null ? await zp.Verify(authority, status ?? "") : null;
if (pending != null)
{
var (_, _, coins) = await svc.BuyCoins(pending.UserId, pending.PackId);
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=success&coins={coins}");
}
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed");
});
app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
{
var (day, last, avail) = await svc.GetDaily(Uid(u));
+6
View File
@@ -17,5 +17,11 @@
},
"ConnectionStrings": {
"Default": "Data Source=hokm.db"
},
"Zarinpal": {
"MerchantId": "299685fb-cadf-4dfc-98e2-d4af5d81528d",
"Sandbox": true,
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
"ClientReturnUrl": "http://localhost:3000"
}
}