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 }; } }