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:
@@ -68,6 +68,13 @@ PAY_PUBLIC_URL=https://pay.flatrender.ir
|
|||||||
# payment which unit YOUR merchant settles in, then set this to match.
|
# payment which unit YOUR merchant settles in, then set this to match.
|
||||||
ZARINPAL_AMOUNT_UNIT=rial
|
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) ────────────────
|
# ── Payments (fill the providers you use; leave others blank) ────────────────
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ services:
|
|||||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||||
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
|
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
|
||||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
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__SecretKey: "${STRIPE_SECRET_KEY:-}"
|
||||||
Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}"
|
Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}"
|
||||||
SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}"
|
SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}"
|
||||||
|
|||||||
+8
-1
@@ -9,10 +9,17 @@ public interface IPaymentService
|
|||||||
Task<PaymentResponse> GetByIdAsync(Guid paymentId, Guid userId);
|
Task<PaymentResponse> GetByIdAsync(Guid paymentId, Guid userId);
|
||||||
|
|
||||||
// ── ZarinPal ────────────────────────────────────────────────────────────────
|
// ── 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> InitiateZarinPalAsync(Guid paymentId, Guid userId);
|
||||||
Task<string> HandleZarinPalCallbackAsync(string authority, string status);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── SnapPay ──────────────────────────────────────────────────────────────────
|
||||||
/// <summary>Calls SnapPay token API and returns the snappay.ir redirect URL.</summary>
|
/// <summary>Calls SnapPay token API and returns the snappay.ir redirect URL.</summary>
|
||||||
Task<string> InitiateSnapPayAsync(Guid paymentId, Guid userId);
|
Task<string> InitiateSnapPayAsync(Guid paymentId, Guid userId);
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ public class PaymentService(
|
|||||||
private string StripeWebhookSecret => config["Stripe:WebhookSecret"] ?? "";
|
private string StripeWebhookSecret => config["Stripe:WebhookSecret"] ?? "";
|
||||||
private const long IrrToToman = 10; // 1 Toman = 10 Rials
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async Task<PagedResponse<PaymentResponse>> GetUserPaymentsAsync(Guid userId, int page, int pageSize)
|
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)
|
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
|
||||||
?? throw new KeyNotFoundException("Payment not found or already processed");
|
?? 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))
|
if (string.IsNullOrEmpty(ZarinPalMerchantId))
|
||||||
throw new InvalidOperationException("ZarinPal:MerchantId is not configured");
|
throw new InvalidOperationException("ZarinPal:MerchantId is not configured");
|
||||||
|
|
||||||
@@ -167,6 +180,114 @@ public class PaymentService(
|
|||||||
return "/payment/result?status=failed&gateway=zarinpal";
|
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 ────────────────────────────────────────────────────────
|
// ── SnapPay initiation ────────────────────────────────────────────────────────
|
||||||
// API ref: https://developer.snappay.ir/
|
// API ref: https://developer.snappay.ir/
|
||||||
// Flow: POST /api/v1/payment/token → get paymentToken → redirect to snappay.ir/payment/{token}
|
// 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);
|
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 ──────────────────────────────────────────────────────────────
|
// ── SnapPay flow ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -292,6 +292,20 @@ func (s *Store) EnqueueWebhook(ctx context.Context, txnID uuid.UUID, url string,
|
|||||||
return id, err
|
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.
|
// ClaimDueWebhooks returns undelivered webhooks whose next_attempt_at has passed.
|
||||||
func (s *Store) ClaimDueWebhooks(ctx context.Context, limit int) ([]*WebhookDelivery, error) {
|
func (s *Store) ClaimDueWebhooks(ctx context.Context, limit int) ([]*WebhookDelivery, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
|||||||
@@ -57,9 +57,20 @@ func (d *Dispatcher) Enqueue(ctx context.Context, client *models.ClientApp, t *m
|
|||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
sig := signing.Sign(client.Secret, body)
|
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)
|
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.
|
// Run starts the delivery loop until ctx is cancelled.
|
||||||
|
|||||||
Reference in New Issue
Block a user