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
@@ -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<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 ---
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? 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<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
// 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) =>
+6
View File
@@ -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": "",