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: "⚠️" });
}