feat(payments): route coin purchases through FlatRender Pay broker
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 56s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 3m38s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-16 00:36:12 +03:30
parent 8262fa79b3
commit d05cce6550
5 changed files with 170 additions and 8 deletions
+7
View File
@@ -56,6 +56,13 @@ services:
Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true} Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true}
Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback} Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback}
Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500} 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. # Store in-app billing verification (Cafe Bazaar / Myket) — fill from panels.
Iab__PackageName: ${IAB_PACKAGE_NAME:-com.bargevasat.app} Iab__PackageName: ${IAB_PACKAGE_NAME:-com.bargevasat.app}
Iab__BazaarClientId: ${IAB_BAZAAR_CLIENT_ID:-} Iab__BazaarClientId: ${IAB_BAZAAR_CLIENT_ID:-}
@@ -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
{
/// <summary>Broker base URL, e.g. https://pay.flatrender.ir</summary>
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
/// <summary>Client app api key (pk_...) issued by the FlatRender pay admin.</summary>
public string ApiKey { get; set; } = "";
/// <summary>Shared HMAC secret (sk_...). Signs requests + verifies webhooks.</summary>
public string Secret { get; set; } = "";
/// <summary>Where the broker sends the user's browser back after payment.</summary>
public string ReturnUrl { get; set; } = "https://bargevasat.ir/?pay=done";
}
/// <summary>
/// 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.
/// </summary>
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<string, byte> _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();
}
/// <summary>Create a payment at the broker; returns the StartPay URL to redirect to.</summary>
public async Task<string?> 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<byte>(); }
}
/// <summary>Returns true the first time a transaction id is seen (idempotency guard).</summary>
public bool MarkProcessed(string transactionId) =>
_processed.TryAdd(transactionId, 1);
}
/// <summary>Shape of the broker webhook body (snake_case JSON).</summary>
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; }
}
+42 -3
View File
@@ -47,6 +47,13 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df
builder.Services.AddSingleton(zp); builder.Services.AddSingleton(zp);
builder.Services.AddSingleton<ZarinpalService>(); builder.Services.AddSingleton<ZarinpalService>();
// --- 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<FlatPayOptions>() ?? new FlatPayOptions();
builder.Services.AddSingleton(flatpay);
builder.Services.AddSingleton<FlatPayService>();
// --- Store in-app billing (Cafe Bazaar / Myket) verification --- // --- Store in-app billing (Cafe Bazaar / Myket) verification ---
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions(); var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
builder.Services.AddSingleton(iab); 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); return Results.Json(new { reward, profile = p }, JsonOpts.Default);
}).RequireAuthorization(); }).RequireAuthorization();
// ZarinPal: create a payment → returns the StartPay URL to redirect to. // Create a payment → returns the StartPay URL to redirect to. Prefers the shared
app.MapPost("/api/coins/pay/request", async (ClaimsPrincipal u, ZarinpalService zp, BuyReq req) => // 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); var pack = ProfileService.Packs.FirstOrDefault(p => p.Id == req.PackId);
if (pack == null) return Results.BadRequest(new { ok = false }); 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 }); return url != null ? Results.Json(new { ok = true, url }) : Results.Json(new { ok = false });
}).RequireAuthorization(); }).RequireAuthorization();
// ZarinPal redirects the browser here after payment (no JWT — authority is the secret). // 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) => 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; 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"); 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<FlatPayWebhook>(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 // 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). // 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) => app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabService iab, IabVerifyReq req) =>
+6
View File
@@ -24,6 +24,12 @@
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback", "CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
"ClientReturnUrl": "http://localhost:3000" "ClientReturnUrl": "http://localhost:3000"
}, },
"FlatPay": {
"BaseUrl": "https://pay.flatrender.ir",
"ApiKey": "",
"Secret": "",
"ReturnUrl": "https://bargevasat.ir/?pay=done"
},
"Iab": { "Iab": {
"PackageName": "com.bargevasat.app", "PackageName": "com.bargevasat.app",
"BazaarClientId": "", "BazaarClientId": "",
+11 -5
View File
@@ -82,22 +82,28 @@ export default function Page() {
useEffect(() => { useEffect(() => {
init(); 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 params = new URLSearchParams(window.location.search);
const pay = params.get("pay"); const pay = params.get("pay");
if (pay) { const brokerStatus = params.get("status"); // Paid | Failed | Cancelled | Expired
if (pay === "success") { if (pay || brokerStatus) {
const ok = pay === "success" || brokerStatus === "Paid";
if (ok) {
const coins = params.get("coins"); const coins = params.get("coins");
pushNotification({ pushNotification({
kind: "system", kind: "system",
titleFa: "پرداخت موفق", titleFa: "پرداخت موفق",
titleEn: "Payment successful", titleEn: "Payment successful",
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined, bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : "سکه‌ها به‌زودی به حساب شما اضافه می‌شوند",
bodyEn: coins ? `${coins} coins added` : undefined, bodyEn: coins ? `${coins} coins added` : "Your coins will be credited shortly",
icon: "💰", icon: "💰",
route: "shop", route: "shop",
}); });
useSessionStore.getState().refreshProfile(); useSessionStore.getState().refreshProfile();
// Re-refresh shortly after, in case the webhook lands a moment later.
setTimeout(() => useSessionStore.getState().refreshProfile(), 4000);
} else { } else {
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" }); pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
} }