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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user