feat(payments): route coin purchases through FlatRender Pay broker
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:
@@ -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; }
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user