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
+7
View File
@@ -68,6 +68,13 @@ PAY_PUBLIC_URL=https://pay.flatrender.ir
# payment which unit YOUR merchant settles in, then set this to match.
ZARINPAL_AMOUNT_UNIT=rial
# FlatRender's OWN plan purchases through the broker. Create a "flatrender" client
# app in Admin → پرداخت (allowed origin https://api.flatrender.ir), then paste its
# key+secret here. Empty ⇒ identity calls ZarinPal directly (legacy).
FLATPAY_FLATRENDER_API_KEY=
FLATPAY_FLATRENDER_SECRET=
FLATPAY_RETURN_BASE=https://api.flatrender.ir
# ── Payments (fill the providers you use; leave others blank) ────────────────
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
+7
View File
@@ -89,6 +89,13 @@ services:
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
# FlatRender Pay broker — when ApiKey+Secret are set, plan purchases route
# through pay.flatrender.ir (the single ZarinPal-verified domain) instead of a
# direct ZarinPal call. ReturnBase = this identity service's public base.
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
FlatPay__ApiKey: "${FLATPAY_FLATRENDER_API_KEY:-}"
FlatPay__Secret: "${FLATPAY_FLATRENDER_SECRET:-}"
FlatPay__ReturnBase: "${FLATPAY_RETURN_BASE:-https://api.flatrender.ir}"
Stripe__SecretKey: "${STRIPE_SECRET_KEY:-}"
Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}"
SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}"
@@ -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>
+14
View File
@@ -292,6 +292,20 @@ func (s *Store) EnqueueWebhook(ctx context.Context, txnID uuid.UUID, url string,
return id, err
}
// GetWebhook loads a single delivery row (used for the inline immediate attempt).
func (s *Store) GetWebhook(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) {
var w WebhookDelivery
err := s.pool.QueryRow(ctx, `
SELECT id, transaction_id, url, payload, signature, attempts
FROM payment.webhook_deliveries
WHERE id = $1 AND delivered = FALSE`, id).Scan(
&w.ID, &w.TransactionID, &w.URL, &w.Payload, &w.Signature, &w.Attempts)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &w, err
}
// ClaimDueWebhooks returns undelivered webhooks whose next_attempt_at has passed.
func (s *Store) ClaimDueWebhooks(ctx context.Context, limit int) ([]*WebhookDelivery, error) {
rows, err := s.pool.Query(ctx, `
+12 -1
View File
@@ -57,9 +57,20 @@ func (d *Dispatcher) Enqueue(ctx context.Context, client *models.ClientApp, t *m
}
body, _ := json.Marshal(payload)
sig := signing.Sign(client.Secret, body)
if _, err := d.store.EnqueueWebhook(ctx, t.ID, *client.WebhookURL, body, sig); err != nil {
id, err := d.store.EnqueueWebhook(ctx, t.ID, *client.WebhookURL, body, sig)
if err != nil {
log.Printf("webhook enqueue failed for txn %s: %v", t.ID, err)
return
}
// Best-effort immediate delivery so the client credits near-instantly; the
// retry loop (Run) still covers it if this attempt fails.
go func() {
bg, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if w, err := d.store.GetWebhook(bg, id); err == nil && w != nil {
d.deliver(bg, w)
}
}()
}
// Run starts the delivery loop until ctx is cancelled.