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
+15
View File
@@ -51,6 +51,21 @@ On a CI box with JDK 21 + build-tools 35 you can drop those overrides.
> Remote storage URL `https://maven.myket.ir`, add it to a **maven2 (group)**,
> and point `MIRROR` at the group URL.
## 💳 Payments — ZarinPal (web) vs store billing (Android)
- **Web / PWA**: buying coins uses **ZarinPal** (sandbox now). Flow:
`POST /api/coins/pay/request` → redirect to `StartPay` → ZarinPal →
`GET /api/coins/pay/callback` (server verifies + credits) → back to the app
with `?pay=success`. Merchant id is config-driven (`Zarinpal:MerchantId`,
swap in the admin panel / appsettings; `Sandbox: true`).
- **Cafe Bazaar / Myket (APK)**: app stores in Iran **require their own
in-app billing** — do NOT use ZarinPal inside the store build. Use:
- **Cafe Bazaar**: Poolakey (`ir.cafebazaar.poolakey`) — define in-app products in the Bazaar panel.
- **Myket**: Myket IAB SDK — define products in the Myket panel.
Wire a Capacitor plugin that detects the store build and routes `buyCoins`
to the store SDK; verify the purchase token on the server, then credit coins
via the same `ProfileService.BuyCoins`. (TODO — needs store accounts + product SKUs.)
## Release (Cafe Bazaar / Myket)
1. Generate a keystore: `keytool -genkey -v -keystore bargevasat.keystore -alias bargevasat -keyalg RSA -keysize 2048 -validity 10000`
2. Configure signing in `android/app/build.gradle` (release `signingConfig`).
@@ -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"
}
}
+22
View File
@@ -47,6 +47,28 @@ export default function Page() {
useEffect(() => {
init();
// ZarinPal payment return (?pay=success&coins= / ?pay=failed)
const params = new URLSearchParams(window.location.search);
const pay = params.get("pay");
if (pay) {
if (pay === "success") {
const coins = params.get("coins");
pushNotification({
kind: "system",
titleFa: "پرداخت موفق",
titleEn: "Payment successful",
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined,
bodyEn: coins ? `${coins} coins added` : undefined,
icon: "💰",
});
useSessionStore.getState().refreshProfile();
} else {
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
}
window.history.replaceState({}, "", window.location.pathname);
}
useUIStore.getState().initHistory();
useNotifStore.getState().init();
// surface a daily-reward notification if it's available
@@ -28,6 +28,12 @@ export function BuyCoinsScreen() {
const buy = async (p: CoinPack) => {
setBusy(p.id);
const res = await getService().buyCoins(p.id);
// Live: redirect to the ZarinPal gateway; we credit on return via callback.
if (res.redirectUrl) {
window.location.href = res.redirectUrl;
return;
}
// Mock/offline: instant credit.
if (res.ok && res.profile) {
setProfile(res.profile);
sound.play("purchase");
+2 -1
View File
@@ -119,7 +119,8 @@ export interface OnlineService {
/* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */
getCoinPacks(): Promise<CoinPack[]>;
buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number }>;
/** Mock credits instantly; live returns a `redirectUrl` to the ZarinPal gateway. */
buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number; redirectUrl?: string }>;
}
import { MockOnlineService } from "./mock-service";
+4 -4
View File
@@ -376,9 +376,9 @@ export class SignalrService implements OnlineService {
}
getCoinPacks(): Promise<CoinPack[]> { return this.getJson<CoinPack[]>("/api/coins/packs"); }
async buyCoins(id: string) {
const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>(
"POST", "/api/coins/buy", { packId: id });
if (r.profile) this.cachedProfile = r.profile;
return r;
// Real money → start a ZarinPal payment and hand back the redirect URL.
const r = await this.send<{ ok: boolean; url?: string }>(
"POST", "/api/coins/pay/request", { packId: id });
return { ok: r.ok, coins: 0, redirectUrl: r.url };
}
}