diff --git a/ANDROID.md b/ANDROID.md
index 6aa896a..1ec2877 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -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`).
diff --git a/server/src/Hokm.Server/Payments/ZarinpalService.cs b/server/src/Hokm.Server/Payments/ZarinpalService.cs
new file mode 100644
index 0000000..4e10b71
--- /dev/null
+++ b/server/src/Hokm.Server/Payments/ZarinpalService.cs
@@ -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;
+ /// Server URL ZarinPal redirects back to after payment.
+ public string CallbackUrl { get; set; } = "http://localhost:5005/api/coins/pay/callback";
+ /// Where to send the user (the web app) after we finish.
+ public string ClientReturnUrl { get; set; } = "http://localhost:3000";
+}
+
+public sealed record PendingPayment(string UserId, string PackId, int AmountRial);
+
+///
+/// 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.
+///
+public sealed class ZarinpalService
+{
+ private static readonly HttpClient Http = new();
+ private readonly ZarinpalOptions _opts;
+ private readonly ConcurrentDictionary _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";
+
+ /// Create a payment and return the StartPay URL to redirect the user to.
+ public async Task 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;
+ }
+
+ /// Verify a returned payment. Returns the pending row on success.
+ public async Task 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;
+ }
+}
diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs
index 390203b..c1a2750 100644
--- a/server/src/Hokm.Server/Program.cs
+++ b/server/src/Hokm.Server/Program.cs
@@ -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(o =>
});
builder.Services.AddScoped();
+// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
+var zp = builder.Configuration.GetSection("Zarinpal").Get() ?? new ZarinpalOptions();
+if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4dfc-98e2-d4af5d81528d";
+builder.Services.AddSingleton(zp);
+builder.Services.AddSingleton();
+
// --- 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));
diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json
index ae7cf1c..1773d4a 100644
--- a/server/src/Hokm.Server/appsettings.json
+++ b/server/src/Hokm.Server/appsettings.json
@@ -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"
}
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 5dee243..bb4ab67 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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
diff --git a/src/components/screens/BuyCoinsScreen.tsx b/src/components/screens/BuyCoinsScreen.tsx
index 859edea..b369a44 100644
--- a/src/components/screens/BuyCoinsScreen.tsx
+++ b/src/components/screens/BuyCoinsScreen.tsx
@@ -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");
diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts
index 04aab58..aa877a5 100644
--- a/src/lib/online/service.ts
+++ b/src/lib/online/service.ts
@@ -119,7 +119,8 @@ export interface OnlineService {
/* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */
getCoinPacks(): Promise;
- 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";
diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts
index d4bb8ff..00632d6 100644
--- a/src/lib/online/signalr-service.ts
+++ b/src/lib/online/signalr-service.ts
@@ -376,9 +376,9 @@ export class SignalrService implements OnlineService {
}
getCoinPacks(): Promise { return this.getJson("/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 };
}
}