feat(payment): route FlatRender plan purchases through the broker
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Failing after 11m4s

- identity: when FlatPay (broker) is configured, InitiateZarinPalAsync
  routes through pay.flatrender.ir instead of calling ZarinPal directly;
  new HandleBrokerCallbackAsync confirms the payment via the broker
  inquiry API (authoritative, not trusting the redirect) and activates
  the plan. New public endpoint GET /v1/payments/callback/broker
  (already public at the gateway via /callback/*). Env-gated — empty
  FlatPay__ApiKey keeps the legacy direct-ZarinPal path.
- broker: deliver webhooks inline on enqueue (best-effort) in addition
  to the retry loop, so clients credit near-instantly (db.GetWebhook +
  goroutine kick).
- compose + ENV_FILE: FlatPay__* for identity (FLATPAY_FLATRENDER_*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-16 00:34:45 +03:30
parent ec51e87d2d
commit 376cdf6a1c
7 changed files with 187 additions and 2 deletions
@@ -9,10 +9,17 @@ public interface IPaymentService
Task<PaymentResponse> GetByIdAsync(Guid paymentId, Guid userId);
// ── ZarinPal ────────────────────────────────────────────────────────────────
/// <summary>Calls ZarinPal request API and returns the zarinpal.com redirect URL.</summary>
/// <summary>Calls ZarinPal request API and returns the zarinpal.com redirect URL.
/// When the FlatRender Pay broker is configured, routes through it instead
/// (the broker owns the single ZarinPal-verified domain pay.flatrender.ir).</summary>
Task<string> InitiateZarinPalAsync(Guid paymentId, Guid userId);
Task<string> HandleZarinPalCallbackAsync(string authority, string status);
// ── FlatRender Pay broker (pay.flatrender.ir) ─────────────────────────────────
/// <summary>Confirms a broker payment via the inquiry API and activates the plan.
/// Called from the broker's return redirect (/v1/payments/callback/broker).</summary>
Task<string> HandleBrokerCallbackAsync(Guid paymentId, string brokerTxnId);
// ── SnapPay ──────────────────────────────────────────────────────────────────
/// <summary>Calls SnapPay token API and returns the snappay.ir redirect URL.</summary>
Task<string> InitiateSnapPayAsync(Guid paymentId, Guid userId);
@@ -36,6 +36,15 @@ public class PaymentService(
private string StripeWebhookSecret => config["Stripe:WebhookSecret"] ?? "";
private const long IrrToToman = 10; // 1 Toman = 10 Rials
// FlatRender Pay broker (pay.flatrender.ir) — routes ZarinPal through the single
// verified domain. When configured, plan purchases go through it instead of a
// direct ZarinPal call (so FlatRender shares the same broker as meezi/bargevasat).
private string FlatPayBaseUrl => config["FlatPay:BaseUrl"] ?? "https://pay.flatrender.ir";
private string FlatPayApiKey => config["FlatPay:ApiKey"] ?? "";
private string FlatPaySecret => config["FlatPay:Secret"] ?? "";
private string FlatPayReturnBase => config["FlatPay:ReturnBase"] ?? "https://api.flatrender.ir";
private bool BrokerEnabled => !string.IsNullOrEmpty(FlatPayApiKey) && !string.IsNullOrEmpty(FlatPaySecret);
// ── Queries ───────────────────────────────────────────────────────────────────
public async Task<PagedResponse<PaymentResponse>> GetUserPaymentsAsync(Guid userId, int page, int pageSize)
@@ -70,6 +79,10 @@ public class PaymentService(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
// Prefer the shared broker (single ZarinPal-verified domain) when configured.
if (BrokerEnabled)
return await InitiateViaBrokerAsync(payment);
if (string.IsNullOrEmpty(ZarinPalMerchantId))
throw new InvalidOperationException("ZarinPal:MerchantId is not configured");
@@ -167,6 +180,114 @@ public class PaymentService(
return "/payment/result?status=failed&gateway=zarinpal";
}
// ── FlatRender Pay broker (pay.flatrender.ir) ─────────────────────────────────
private string BrokerSign(byte[] message)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(FlatPaySecret));
return Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();
}
/// <summary>Create the payment at the broker and return its StartPay URL.</summary>
private async Task<string> InitiateViaBrokerAsync(Payment payment)
{
var amountToman = payment.AmountMinor / IrrToToman;
var returnUrl = $"{FlatPayReturnBase.TrimEnd('/')}/v1/payments/callback/broker?payment_id={payment.Id}";
var body = new
{
amount = amountToman,
currency = "IRT",
description = payment.Title ?? "پرداخت فلت‌رندر",
client_ref = payment.Id.ToString(),
return_url = returnUrl,
metadata = new { payment_id = payment.Id.ToString() },
};
var json = JsonSerializer.Serialize(body);
var bytes = Encoding.UTF8.GetBytes(json);
var http = httpClientFactory.CreateClient("broker");
using var req = new HttpRequestMessage(HttpMethod.Post, $"{FlatPayBaseUrl.TrimEnd('/')}/v1/pay/request");
req.Headers.TryAddWithoutValidation("X-Api-Key", FlatPayApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", BrokerSign(bytes));
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var resp = await http.SendAsync(req);
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = doc!.RootElement;
if (!resp.IsSuccessStatusCode || !root.TryGetProperty("payment_url", out var urlEl))
{
var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "broker error";
throw new InvalidOperationException($"FlatPay request failed: {msg}");
}
// Persist the broker transaction id so the return callback can confirm it.
if (root.TryGetProperty("id", out var idEl))
payment.GatewayToken = idEl.GetString();
payment.GatewayOrderId = "flatpay";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return urlEl.GetString()!;
}
/// <summary>
/// Broker return — authoritatively confirm via the inquiry API (do NOT trust the
/// redirect alone), then activate the plan.
/// </summary>
public async Task<string> HandleBrokerCallbackAsync(Guid paymentId, string brokerTxnId)
{
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == paymentId)
?? throw new KeyNotFoundException("Payment not found");
if (payment.Status == PaymentStatus.Succeeded)
return $"/payment/result?status=success&ref={payment.GatewayTrackId}";
var txnId = !string.IsNullOrEmpty(brokerTxnId) ? brokerTxnId : payment.GatewayToken ?? "";
if (string.IsNullOrEmpty(txnId))
return "/payment/result?status=failed&gateway=broker";
var json = JsonSerializer.Serialize(new { id = txnId });
var bytes = Encoding.UTF8.GetBytes(json);
var http = httpClientFactory.CreateClient("broker");
using var req = new HttpRequestMessage(HttpMethod.Post, $"{FlatPayBaseUrl.TrimEnd('/')}/v1/pay/inquiry");
req.Headers.TryAddWithoutValidation("X-Api-Key", FlatPayApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", BrokerSign(bytes));
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var status = "";
string? refId = null;
try
{
var resp = await http.SendAsync(req);
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = doc!.RootElement;
if (root.TryGetProperty("status", out var st)) status = st.GetString() ?? "";
if (root.TryGetProperty("ref_id", out var r) && r.ValueKind == JsonValueKind.String) refId = r.GetString();
}
catch { /* broker unreachable — leave pending, user can retry */ }
if (status == "Paid")
{
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = refId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={refId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = $"broker status: {status}";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=broker";
}
// ── SnapPay initiation ────────────────────────────────────────────────────────
// API ref: https://developer.snappay.ir/
// Flow: POST /api/v1/payment/token → get paymentToken → redirect to snappay.ir/payment/{token}
@@ -65,6 +65,24 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase
return Redirect(frontendUrl);
}
// ── FlatRender Pay broker flow ────────────────────────────────────────────────
/// <summary>
/// GET /v1/payments/callback/broker?payment_id={p}&amp;id={brokerTxn}&amp;status=&amp;sign=
/// The broker redirects the user's browser here after payment. We confirm the
/// transaction authoritatively via the broker inquiry API, activate the plan,
/// then redirect to the frontend result page. Public (no JWT).
/// </summary>
[AllowAnonymous]
[HttpGet("payments/callback/broker")]
public async Task<IActionResult> BrokerCallback(
[FromQuery] Guid payment_id,
[FromQuery] string? id)
{
var frontendUrl = await paymentService.HandleBrokerCallbackAsync(payment_id, id ?? "");
return Redirect(frontendUrl);
}
// ── SnapPay flow ──────────────────────────────────────────────────────────────
/// <summary>