From d05cce6550332d1e2406f1cf01b5afdd08da965c Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 16 Jun 2026 00:36:12 +0330 Subject: [PATCH] feat(payments): route coin purchases through FlatRender Pay broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZarinPal only accepts callbacks on pay.flatrender.ir, so bargevasat pays through the shared broker and is credited via a signed webhook. - FlatPayService: broker client (HMAC-signed /v1/pay/request) + webhook signature verification + in-memory idempotency guard. - Program.cs: /api/coins/pay/request prefers the broker when configured (FlatPay__ApiKey/Secret set), else the legacy direct ZarinPal path; new public POST /api/coins/pay/webhook verifies the HMAC and credits coins from the echoed metadata (idempotent). - appsettings + docker-compose: FlatPay config (empty ⇒ legacy path). - web: recognise the broker's ?status=Paid return + re-refresh profile (coins are credited server-side via webhook). Co-Authored-By: Claude Opus 4.8 --- docker-compose.yml | 7 ++ .../Hokm.Server/Payments/FlatPayService.cs | 104 ++++++++++++++++++ server/src/Hokm.Server/Program.cs | 45 +++++++- server/src/Hokm.Server/appsettings.json | 6 + src/app/page.tsx | 16 ++- 5 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 server/src/Hokm.Server/Payments/FlatPayService.cs diff --git a/docker-compose.yml b/docker-compose.yml index 1844433..0e7b20f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,13 @@ services: Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true} Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback} Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500} + # FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal via the single + # verified domain. Set FLATPAY_API_KEY + FLATPAY_SECRET to route through it + # (issued in FlatRender admin → پرداخت). Empty ⇒ legacy direct ZarinPal above. + FlatPay__BaseUrl: ${FLATPAY_BASE_URL:-https://pay.flatrender.ir} + FlatPay__ApiKey: ${FLATPAY_API_KEY:-} + FlatPay__Secret: ${FLATPAY_SECRET:-} + FlatPay__ReturnUrl: ${FLATPAY_RETURN_URL:-https://bargevasat.ir/?pay=done} # Store in-app billing verification (Cafe Bazaar / Myket) — fill from panels. Iab__PackageName: ${IAB_PACKAGE_NAME:-com.bargevasat.app} Iab__BazaarClientId: ${IAB_BAZAAR_CLIENT_ID:-} diff --git a/server/src/Hokm.Server/Payments/FlatPayService.cs b/server/src/Hokm.Server/Payments/FlatPayService.cs new file mode 100644 index 0000000..7829fbc --- /dev/null +++ b/server/src/Hokm.Server/Payments/FlatPayService.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Hokm.Server.Payments; + +public sealed class FlatPayOptions +{ + /// Broker base URL, e.g. https://pay.flatrender.ir + public string BaseUrl { get; set; } = "https://pay.flatrender.ir"; + /// Client app api key (pk_...) issued by the FlatRender pay admin. + public string ApiKey { get; set; } = ""; + /// Shared HMAC secret (sk_...). Signs requests + verifies webhooks. + public string Secret { get; set; } = ""; + /// Where the broker sends the user's browser back after payment. + public string ReturnUrl { get; set; } = "https://bargevasat.ir/?pay=done"; +} + +/// +/// Routes coin purchases through the shared FlatRender ZarinPal broker +/// (pay.flatrender.ir) — ZarinPal only accepts callbacks on that one verified +/// domain, so bargevasat.ir pays through the broker and is credited via a signed +/// webhook. When ApiKey/Secret are unset this is disabled and the legacy direct +/// ZarinpalService path is used instead. +/// +public sealed class FlatPayService +{ + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) }; + private readonly FlatPayOptions _opts; + // Idempotency: broker webhooks may be delivered more than once. + private readonly ConcurrentDictionary _processed = new(); + + public FlatPayService(FlatPayOptions opts) => _opts = opts; + + public bool Enabled => + !string.IsNullOrWhiteSpace(_opts.ApiKey) && !string.IsNullOrWhiteSpace(_opts.Secret); + + private string Sign(byte[] message) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret)); + return Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant(); + } + + /// Create a payment at the broker; returns the StartPay URL to redirect to. + public async Task Request(string userId, string packId, int priceToman, string description) + { + var payload = new + { + amount = priceToman, + currency = "IRT", + description, + client_ref = Guid.NewGuid().ToString("N"), + return_url = _opts.ReturnUrl, + metadata = new { user_id = userId, pack_id = packId }, + }; + var json = JsonSerializer.Serialize(payload); + var bytes = Encoding.UTF8.GetBytes(json); + + using var req = new HttpRequestMessage(HttpMethod.Post, $"{_opts.BaseUrl.TrimEnd('/')}/v1/pay/request"); + req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey); + req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes)); + req.Content = new ByteArrayContent(bytes); + req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + try + { + var resp = await Http.SendAsync(req); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (resp.IsSuccessStatusCode && + doc.RootElement.TryGetProperty("payment_url", out var url)) + return url.GetString(); + } + catch { /* broker unreachable */ } + return null; + } + + public bool VerifyWebhook(byte[] rawBody, string? signature) => + !string.IsNullOrEmpty(signature) && + CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(Sign(rawBody)), + SafeHex(signature)); + + private static byte[] SafeHex(string s) + { + try { return Convert.FromHexString(s); } + catch { return Array.Empty(); } + } + + /// Returns true the first time a transaction id is seen (idempotency guard). + public bool MarkProcessed(string transactionId) => + _processed.TryAdd(transactionId, 1); +} + +/// Shape of the broker webhook body (snake_case JSON). +public sealed class FlatPayWebhook +{ + public string? Event { get; set; } + public string? Id { get; set; } + public string? Status { get; set; } + public string? Ref_Id { get; set; } + public JsonElement Metadata { get; set; } +} diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index 1cf99e5..ffff5bc 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -47,6 +47,13 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df builder.Services.AddSingleton(zp); builder.Services.AddSingleton(); +// --- FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal merchant via the +// single verified callback domain. Preferred when configured; otherwise the +// direct ZarinpalService above is used. --- +var flatpay = builder.Configuration.GetSection("FlatPay").Get() ?? new FlatPayOptions(); +builder.Services.AddSingleton(flatpay); +builder.Services.AddSingleton(); + // --- Store in-app billing (Cafe Bazaar / Myket) verification --- var iab = builder.Configuration.GetSection("Iab").Get() ?? new IabOptions(); builder.Services.AddSingleton(iab); @@ -227,16 +234,21 @@ 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) => +// Create a payment → returns the StartPay URL to redirect to. Prefers the shared +// FlatRender Pay broker (single verified ZarinPal domain) when configured. +app.MapPost("/api/coins/pay/request", async (ClaimsPrincipal u, ZarinpalService zp, FlatPayService fp, 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} سکه برگ وسط"); + var desc = $"خرید {pack.Coins + pack.Bonus} سکه برگ وسط"; + var url = fp.Enabled + ? await fp.Request(Uid(u), pack.Id, pack.PriceToman, desc) + : await zp.Request(Uid(u), pack.Id, pack.PriceToman, desc); 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). +// Legacy direct path (used when the broker is not configured). 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; @@ -248,6 +260,33 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status, return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed"); }); +// FlatRender Pay broker webhook (server-to-server, HMAC-signed) → credit coins. +// Idempotent: the broker may deliver more than once. +app.MapPost("/api/coins/pay/webhook", async (HttpRequest http, FlatPayService fp, ProfileService svc) => +{ + using var ms = new MemoryStream(); + await http.Body.CopyToAsync(ms); + var raw = ms.ToArray(); + if (!fp.VerifyWebhook(raw, http.Headers["X-FlatPay-Signature"])) + return Results.Unauthorized(); + + var ev = JsonSerializer.Deserialize(raw, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (ev?.Id == null || !string.Equals(ev.Status, "Paid", StringComparison.OrdinalIgnoreCase)) + return Results.Ok(new { ok = true }); // ack non-paid events (no retry) + + if (!fp.MarkProcessed(ev.Id)) return Results.Ok(new { ok = true, duplicate = true }); + + string? userId = ev.Metadata.ValueKind == JsonValueKind.Object && + ev.Metadata.TryGetProperty("user_id", out var uid) ? uid.GetString() : null; + string? packId = ev.Metadata.ValueKind == JsonValueKind.Object && + ev.Metadata.TryGetProperty("pack_id", out var pid) ? pid.GetString() : null; + if (userId != null && packId != null) + await svc.BuyCoins(userId, packId); + + return Results.Ok(new { ok = true }); +}); + // Store in-app purchase (Cafe Bazaar / Myket): the client sends the store purchase // token; we verify it server-to-server, then credit the matching pack (SKU == packId). app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabService iab, IabVerifyReq req) => diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json index 3c37053..7726476 100644 --- a/server/src/Hokm.Server/appsettings.json +++ b/server/src/Hokm.Server/appsettings.json @@ -24,6 +24,12 @@ "CallbackUrl": "http://localhost:5005/api/coins/pay/callback", "ClientReturnUrl": "http://localhost:3000" }, + "FlatPay": { + "BaseUrl": "https://pay.flatrender.ir", + "ApiKey": "", + "Secret": "", + "ReturnUrl": "https://bargevasat.ir/?pay=done" + }, "Iab": { "PackageName": "com.bargevasat.app", "BazaarClientId": "", diff --git a/src/app/page.tsx b/src/app/page.tsx index 4c42985..4d370ef 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -82,22 +82,28 @@ export default function Page() { useEffect(() => { init(); - // ZarinPal payment return (?pay=success&coins= / ?pay=failed) + // Payment return — legacy direct ZarinPal (?pay=success&coins= / ?pay=failed) + // OR the FlatRender Pay broker (?pay=done&status=Paid|Failed&id=…&sign=…). + // With the broker, coins are credited server-side via webhook; we just refresh. const params = new URLSearchParams(window.location.search); const pay = params.get("pay"); - if (pay) { - if (pay === "success") { + const brokerStatus = params.get("status"); // Paid | Failed | Cancelled | Expired + if (pay || brokerStatus) { + const ok = pay === "success" || brokerStatus === "Paid"; + if (ok) { const coins = params.get("coins"); pushNotification({ kind: "system", titleFa: "پرداخت موفق", titleEn: "Payment successful", - bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined, - bodyEn: coins ? `${coins} coins added` : undefined, + bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : "سکه‌ها به‌زودی به حساب شما اضافه می‌شوند", + bodyEn: coins ? `${coins} coins added` : "Your coins will be credited shortly", icon: "💰", route: "shop", }); useSessionStore.getState().refreshProfile(); + // Re-refresh shortly after, in case the webhook lands a moment later. + setTimeout(() => useSessionStore.getState().refreshProfile(), 4000); } else { pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" }); }