diff --git a/deploy/ENV_FILE.production.example b/deploy/ENV_FILE.production.example index 94ceae7..9d33408 100644 --- a/deploy/ENV_FILE.production.example +++ b/deploy/ENV_FILE.production.example @@ -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= diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index 140f75a..799ef1b 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -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:-}" diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPaymentService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPaymentService.cs index 7d9bbc8..063aab4 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPaymentService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPaymentService.cs @@ -9,10 +9,17 @@ public interface IPaymentService Task GetByIdAsync(Guid paymentId, Guid userId); // ── ZarinPal ──────────────────────────────────────────────────────────────── - /// Calls ZarinPal request API and returns the zarinpal.com redirect URL. + /// 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). Task InitiateZarinPalAsync(Guid paymentId, Guid userId); Task HandleZarinPalCallbackAsync(string authority, string status); + // ── FlatRender Pay broker (pay.flatrender.ir) ───────────────────────────────── + /// Confirms a broker payment via the inquiry API and activates the plan. + /// Called from the broker's return redirect (/v1/payments/callback/broker). + Task HandleBrokerCallbackAsync(Guid paymentId, string brokerTxnId); + // ── SnapPay ────────────────────────────────────────────────────────────────── /// Calls SnapPay token API and returns the snappay.ir redirect URL. Task InitiateSnapPayAsync(Guid paymentId, Guid userId); diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/PaymentService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/PaymentService.cs index 0b98120..18b93f1 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/PaymentService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/PaymentService.cs @@ -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> 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(); + } + + /// Create the payment at the broker and return its StartPay URL. + private async Task 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(); + 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()!; + } + + /// + /// Broker return — authoritatively confirm via the inquiry API (do NOT trust the + /// redirect alone), then activate the plan. + /// + public async Task 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(); + 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} diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs index 8256912..9b1e7f8 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs @@ -65,6 +65,24 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase return Redirect(frontendUrl); } + // ── FlatRender Pay broker flow ──────────────────────────────────────────────── + + /// + /// 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). + /// + [AllowAnonymous] + [HttpGet("payments/callback/broker")] + public async Task BrokerCallback( + [FromQuery] Guid payment_id, + [FromQuery] string? id) + { + var frontendUrl = await paymentService.HandleBrokerCallbackAsync(payment_id, id ?? ""); + return Redirect(frontendUrl); + } + // ── SnapPay flow ────────────────────────────────────────────────────────────── /// diff --git a/services/payment/internal/db/db.go b/services/payment/internal/db/db.go index c450bc9..0b16351 100644 --- a/services/payment/internal/db/db.go +++ b/services/payment/internal/db/db.go @@ -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, ` diff --git a/services/payment/internal/handlers/webhooks.go b/services/payment/internal/handlers/webhooks.go index dd0d8c0..0a890b0 100644 --- a/services/payment/internal/handlers/webhooks.go +++ b/services/payment/internal/handlers/webhooks.go @@ -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.