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:
+15
@@ -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)**,
|
> Remote storage URL `https://maven.myket.ir`, add it to a **maven2 (group)**,
|
||||||
> and point `MIRROR` at the group URL.
|
> 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)
|
## Release (Cafe Bazaar / Myket)
|
||||||
1. Generate a keystore: `keytool -genkey -v -keystore bargevasat.keystore -alias bargevasat -keyalg RSA -keysize 2048 -validity 10000`
|
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`).
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Hokm.Server.Auth;
|
|||||||
using Hokm.Server.Data;
|
using Hokm.Server.Data;
|
||||||
using Hokm.Server.Game;
|
using Hokm.Server.Game;
|
||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
|
using Hokm.Server.Payments;
|
||||||
using Hokm.Server.Profiles;
|
using Hokm.Server.Profiles;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -32,6 +33,12 @@ builder.Services.AddDbContext<AppDbContext>(o =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddScoped<ProfileService>();
|
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) ---
|
// --- SignalR (camelCase to match the TS client) ---
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddSignalR()
|
.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);
|
return Results.Json(new { reward, profile = p }, JsonOpts.Default);
|
||||||
}).RequireAuthorization();
|
}).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) =>
|
app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||||
{
|
{
|
||||||
var (day, last, avail) = await svc.GetDaily(Uid(u));
|
var (day, last, avail) = await svc.GetDaily(Uid(u));
|
||||||
|
|||||||
@@ -17,5 +17,11 @@
|
|||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Data Source=hokm.db"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ export default function Page() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init();
|
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();
|
useUIStore.getState().initHistory();
|
||||||
useNotifStore.getState().init();
|
useNotifStore.getState().init();
|
||||||
// surface a daily-reward notification if it's available
|
// surface a daily-reward notification if it's available
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export function BuyCoinsScreen() {
|
|||||||
const buy = async (p: CoinPack) => {
|
const buy = async (p: CoinPack) => {
|
||||||
setBusy(p.id);
|
setBusy(p.id);
|
||||||
const res = await getService().buyCoins(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) {
|
if (res.ok && res.profile) {
|
||||||
setProfile(res.profile);
|
setProfile(res.profile);
|
||||||
sound.play("purchase");
|
sound.play("purchase");
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ export interface OnlineService {
|
|||||||
|
|
||||||
/* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */
|
/* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */
|
||||||
getCoinPacks(): Promise<CoinPack[]>;
|
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";
|
import { MockOnlineService } from "./mock-service";
|
||||||
|
|||||||
@@ -376,9 +376,9 @@ export class SignalrService implements OnlineService {
|
|||||||
}
|
}
|
||||||
getCoinPacks(): Promise<CoinPack[]> { return this.getJson<CoinPack[]>("/api/coins/packs"); }
|
getCoinPacks(): Promise<CoinPack[]> { return this.getJson<CoinPack[]>("/api/coins/packs"); }
|
||||||
async buyCoins(id: string) {
|
async buyCoins(id: string) {
|
||||||
const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>(
|
// Real money → start a ZarinPal payment and hand back the redirect URL.
|
||||||
"POST", "/api/coins/buy", { packId: id });
|
const r = await this.send<{ ok: boolean; url?: string }>(
|
||||||
if (r.profile) this.cachedProfile = r.profile;
|
"POST", "/api/coins/pay/request", { packId: id });
|
||||||
return r;
|
return { ok: r.ok, coins: 0, redirectUrl: r.url };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user