feat(payment): route FlatRender plan purchases through the broker
- 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:
+8
-1
@@ -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}&id={brokerTxn}&status=&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>
|
||||
|
||||
Reference in New Issue
Block a user