using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using FlatRender.IdentitySvc.Application.Services.Interfaces; using FlatRender.IdentitySvc.Domain.Entities; using FlatRender.IdentitySvc.Domain.Enums; using FlatRender.IdentitySvc.Infrastructure.Data; using FlatRender.IdentitySvc.Models.Responses; using Microsoft.EntityFrameworkCore; namespace FlatRender.IdentitySvc.Application.Services; public class PaymentService( IdentityDbContext db, IHttpClientFactory httpClientFactory, IConfiguration config) : IPaymentService { // ── Config ──────────────────────────────────────────────────────────────────── private string ZarinPalMerchantId => config["ZarinPal:MerchantId"] ?? ""; private string ZarinPalCallbackUrl => config["ZarinPal:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/zarinpal"; private bool ZarinPalSandbox => config["ZarinPal:Sandbox"] == "true"; private string SnapPayClientId => config["SnapPay:ClientId"] ?? ""; private string SnapPayClientSecret => config["SnapPay:ClientSecret"] ?? ""; private string SnapPayBaseUrl => config["SnapPay:BaseUrl"] ?? "https://api.snappay.ir"; private string SnapPayCallbackUrl => config["SnapPay:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/snappay"; // Tara payment gateway (tara.ir) — API key auth // Docs: https://www.tara.ir/documents/payment-gateway private string TaraApiKey => config["Tara:ApiKey"] ?? ""; private string TaraBaseUrl => config["Tara:BaseUrl"] ?? "https://api.tara.ir"; private string TaraCallbackUrl => config["Tara:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/tara"; private string StripeSecretKey => config["Stripe:SecretKey"] ?? ""; 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) { var total = await db.Payments.CountAsync(p => p.UserId == userId); var payments = await db.Payments .Where(p => p.UserId == userId) .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResponse( payments.Select(MapPaymentResponse).ToList(), new PaginationMeta(page, pageSize, total, (long)(page * pageSize) < total) ); } public async Task GetByIdAsync(Guid paymentId, Guid userId) { var payment = await db.Payments .FirstOrDefaultAsync(p => p.Id == paymentId && p.UserId == userId) ?? throw new KeyNotFoundException("Payment not found"); return MapPaymentResponse(payment); } // ── ZarinPal initiation ─────────────────────────────────────────────────────── public async Task InitiateZarinPalAsync(Guid paymentId, Guid userId) { var payment = await db.Payments.FirstOrDefaultAsync( 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"); var amountToman = payment.AmountMinor / IrrToToman; var baseUrl = ZarinPalSandbox ? "https://sandbox.zarinpal.com/pg/v4/payment" : "https://api.zarinpal.com/pg/v4/payment"; var http = httpClientFactory.CreateClient("zarinpal"); var reqBody = new { merchant_id = ZarinPalMerchantId, amount = amountToman, callback_url = ZarinPalCallbackUrl, description = payment.Title ?? "پرداخت فلت‌رندر", metadata = new { order_id = paymentId.ToString() }, }; var resp = await http.PostAsJsonAsync($"{baseUrl}/request.json", reqBody); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync(); var root = json!.RootElement; var code = root.GetProperty("data").GetProperty("code").GetInt32(); if (code != 100) { var errDetail = root.TryGetProperty("errors", out var errEl) ? errEl.ToString() : "no details"; throw new InvalidOperationException($"ZarinPal request failed (code={code}): {errDetail}"); } var authority = root.GetProperty("data").GetProperty("authority").GetString()!; payment.GatewayToken = authority; payment.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); var startPage = ZarinPalSandbox ? "https://sandbox.zarinpal.com/pg/StartPay/" : "https://www.zarinpal.com/pg/StartPay/"; return $"{startPage}{authority}"; } // ── ZarinPal callback (browser returns after payment) ───────────────────────── public async Task HandleZarinPalCallbackAsync(string authority, string status) { if (status != "OK") return "/payment/result?status=failed&gateway=zarinpal"; var payment = await db.Payments.Include(p => p.User) .FirstOrDefaultAsync(p => p.GatewayToken == authority && p.Status == PaymentStatus.Pending) ?? throw new KeyNotFoundException("Payment record not found for this authority"); var amountToman = payment.AmountMinor / IrrToToman; var baseUrl = ZarinPalSandbox ? "https://sandbox.zarinpal.com/pg/v4/payment" : "https://api.zarinpal.com/pg/v4/payment"; var http = httpClientFactory.CreateClient("zarinpal"); var verifyBody = new { merchant_id = ZarinPalMerchantId, amount = amountToman, authority, }; var resp = await http.PostAsJsonAsync($"{baseUrl}/verify.json", verifyBody); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync(); var root = json!.RootElement; var code = root.GetProperty("data").GetProperty("code").GetInt32(); if (code is 100 or 101) // 101 = already verified (idempotent) { var refId = root.GetProperty("data").GetProperty("ref_id").GetInt64().ToString(); 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 = $"ZarinPal verify code: {code}"; payment.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); 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} // Callback query params: paymentToken, merchantOrderId, shapSnapStatus (DONE|FAIL|CANCEL) // Verify: POST /api/v1/payment/verify public async Task InitiateSnapPayAsync(Guid paymentId, Guid userId) { var payment = await db.Payments.FirstOrDefaultAsync( p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending) ?? throw new KeyNotFoundException("Payment not found or already processed"); if (string.IsNullOrEmpty(SnapPayClientId) || string.IsNullOrEmpty(SnapPayClientSecret)) throw new InvalidOperationException("SnapPay:ClientId / SnapPay:ClientSecret are not configured"); var amountToman = payment.AmountMinor / IrrToToman; var http = httpClientFactory.CreateClient("snappay"); var reqBody = new { clientId = SnapPayClientId, clientSecret = SnapPayClientSecret, amount = amountToman, currency = "TOMAN", callbackUrl = SnapPayCallbackUrl, description = payment.Title ?? "پرداخت فلت‌رندر", merchantOrderId = paymentId.ToString(), }; var resp = await http.PostAsJsonAsync($"{SnapPayBaseUrl}/api/v1/payment/token", reqBody); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync(); var root = json!.RootElement; if (!root.TryGetProperty("status", out var statusEl) || !statusEl.GetBoolean()) { var error = root.TryGetProperty("error", out var e) ? e.ToString() : "SnapPay error"; throw new InvalidOperationException($"SnapPay payment request failed: {error}"); } var paymentToken = root.GetProperty("response").GetProperty("paymentToken").GetString()!; payment.GatewayToken = paymentToken; payment.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return $"https://snappay.ir/payment/{paymentToken}"; } public async Task HandleSnapPayCallbackAsync(string paymentToken, string shapStatus) { // shapSnapStatus values: DONE = success, FAIL / CANCEL = failure if (!shapStatus.Equals("DONE", StringComparison.OrdinalIgnoreCase)) return "/payment/result?status=failed&gateway=snappay"; var payment = await db.Payments.Include(p => p.User) .FirstOrDefaultAsync(p => p.GatewayToken == paymentToken && p.Status == PaymentStatus.Pending) ?? throw new KeyNotFoundException("Payment record not found for this token"); var http = httpClientFactory.CreateClient("snappay"); var verifyBody = new { clientId = SnapPayClientId, clientSecret = SnapPayClientSecret, paymentToken, }; var resp = await http.PostAsJsonAsync($"{SnapPayBaseUrl}/api/v1/payment/verify", verifyBody); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync(); var root = json!.RootElement; if (root.TryGetProperty("status", out var okEl) && okEl.GetBoolean()) { var responseObj = root.GetProperty("response"); var transactionId = responseObj.TryGetProperty("transactionId", out var tid) ? tid.ToString() : paymentToken; payment.Status = PaymentStatus.Succeeded; payment.GatewayTrackId = transactionId; payment.ConfirmedAt = DateTime.UtcNow; payment.UpdatedAt = DateTime.UtcNow; if (payment.PlanId.HasValue) await ActivatePlanAsync(payment); await db.SaveChangesAsync(); return $"/payment/result?status=success&ref={transactionId}"; } payment.Status = PaymentStatus.Failed; payment.FailedAt = DateTime.UtcNow; payment.FailureReason = root.TryGetProperty("error", out var err) ? err.ToString() : "SnapPay verification failed"; payment.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return "/payment/result?status=failed&gateway=snappay"; } // ── Tara initiation ──────────────────────────────────────────────────────────── // API ref: https://www.tara.ir/documents/payment-gateway // Auth: X-Api-Key request header // Flow: POST /v1/payment/request → { token, redirectUrl } // → redirect browser to redirectUrl (or fallback: {baseUrl}/payment/{token}) // Callback query params: token, status (OK|FAILED) // Verify: POST /v1/payment/verify body: { token, orderId } public async Task InitiateTaraAsync(Guid paymentId, Guid userId) { var payment = await db.Payments.FirstOrDefaultAsync( p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending) ?? throw new KeyNotFoundException("Payment not found or already processed"); if (string.IsNullOrEmpty(TaraApiKey)) throw new InvalidOperationException("Tara:ApiKey is not configured"); var amountToman = payment.AmountMinor / IrrToToman; var http = httpClientFactory.CreateClient("tara"); using var request = new HttpRequestMessage(HttpMethod.Post, $"{TaraBaseUrl}/v1/payment/request"); request.Headers.Add("X-Api-Key", TaraApiKey); request.Content = JsonContent.Create(new { amount = amountToman, orderId = paymentId.ToString(), callbackUrl = TaraCallbackUrl, description = payment.Title ?? "پرداخت فلت‌رندر", }); var resp = await http.SendAsync(request); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync(); var root = json!.RootElement; if (!root.TryGetProperty("token", out var tokenEl)) { var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "Tara error"; throw new InvalidOperationException($"Tara payment request failed: {msg}"); } var token = tokenEl.GetString()!; payment.GatewayToken = token; payment.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); // Use the redirectUrl Tara returns; fall back to constructed URL if absent if (root.TryGetProperty("redirectUrl", out var urlEl) && !string.IsNullOrEmpty(urlEl.GetString())) return urlEl.GetString()!; return $"{TaraBaseUrl}/payment/{token}"; } public async Task HandleTaraCallbackAsync(string token, string status) { var ok = status.Equals("OK", StringComparison.OrdinalIgnoreCase) || status.Equals("SUCCESS", StringComparison.OrdinalIgnoreCase); if (!ok) return "/payment/result?status=failed&gateway=tara"; var payment = await db.Payments.Include(p => p.User) .FirstOrDefaultAsync(p => p.GatewayToken == token && p.Status == PaymentStatus.Pending) ?? throw new KeyNotFoundException("Payment record not found for this token"); var http = httpClientFactory.CreateClient("tara"); using var request = new HttpRequestMessage(HttpMethod.Post, $"{TaraBaseUrl}/v1/payment/verify"); request.Headers.Add("X-Api-Key", TaraApiKey); request.Content = JsonContent.Create(new { token, orderId = payment.Id.ToString(), }); var resp = await http.SendAsync(request); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadFromJsonAsync(); var root = json!.RootElement; var verified = root.TryGetProperty("status", out var statusEl) && (statusEl.GetString()?.ToUpperInvariant() is "OK" or "SUCCESS" or "VERIFIED"); if (verified) { var trackId = root.TryGetProperty("trackId", out var tid) ? tid.GetString() ?? token : token; payment.Status = PaymentStatus.Succeeded; payment.GatewayTrackId = trackId; payment.ConfirmedAt = DateTime.UtcNow; payment.UpdatedAt = DateTime.UtcNow; if (payment.PlanId.HasValue) await ActivatePlanAsync(payment); await db.SaveChangesAsync(); return $"/payment/result?status=success&ref={trackId}"; } payment.Status = PaymentStatus.Failed; payment.FailedAt = DateTime.UtcNow; payment.FailureReason = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "Tara verification failed"; payment.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return "/payment/result?status=failed&gateway=tara"; } // ── Stripe webhook ──────────────────────────────────────────────────────────── public async Task HandleStripeWebhookAsync(string payload, string signature) { if (!VerifyStripeSignature(payload, signature, StripeWebhookSecret)) throw new UnauthorizedAccessException("Invalid Stripe webhook signature"); using var json = JsonDocument.Parse(payload); var eventType = json.RootElement.GetProperty("type").GetString(); if (eventType != "checkout.session.completed") return; var sessionObj = json.RootElement.GetProperty("data").GetProperty("object"); var metadata = sessionObj.GetProperty("metadata"); if (!metadata.TryGetProperty("payment_id", out var pidEl)) return; if (!Guid.TryParse(pidEl.GetString(), out var paymentId)) return; var payment = await db.Payments.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == paymentId); if (payment is null || payment.Status != PaymentStatus.Pending) return; var piId = sessionObj.TryGetProperty("payment_intent", out var pi) ? pi.GetString() : null; payment.Status = PaymentStatus.Succeeded; payment.GatewayOrderId = piId; payment.ConfirmedAt = DateTime.UtcNow; payment.UpdatedAt = DateTime.UtcNow; if (payment.PlanId.HasValue) await ActivatePlanAsync(payment); await db.SaveChangesAsync(); } // ── Refunds ─────────────────────────────────────────────────────────────────── public async Task IssueRefundAsync( Guid paymentId, long? amountMinor, string reason, string refundTo) { 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) throw new InvalidOperationException("Only succeeded payments can be refunded"); var refundAmount = amountMinor ?? payment.AmountMinor; payment.Status = PaymentStatus.Refunded; payment.RefundedAt = DateTime.UtcNow; payment.RefundAmountMinor = refundAmount; payment.RefundReason = reason; payment.UpdatedAt = DateTime.UtcNow; // Credit back to user balance (default) or affiliate balance if (string.IsNullOrEmpty(refundTo) || refundTo == "Balance") payment.User.BalanceMinor += refundAmount; else if (refundTo == "Affiliate") payment.User.AffiliateBalanceMinor += refundAmount; await db.SaveChangesAsync(); return new RefundResponse(paymentId, "Refunded"); } // ── Helpers ─────────────────────────────────────────────────────────────────── private async Task ActivatePlanAsync(Payment payment) { var plan = await db.Plans.FindAsync(payment.PlanId!.Value); if (plan is null) return; var durationMonths = plan.MonthsDuration ?? plan.BillingPeriod switch { BillingPeriod.Monthly => 1, BillingPeriod.Quarterly => 3, BillingPeriod.SemiAnnual => 6, BillingPeriod.Annual => 12, BillingPeriod.Lifetime => 1200, BillingPeriod.OneTime => 1, _ => 1, }; var now = DateTime.UtcNow; db.UserPlans.Add(new UserPlan { UserId = payment.UserId, TenantId = payment.TenantId, PlanId = plan.Id, PlanCode = plan.Code, PlanName = plan.Name, PriceMinorPaid = payment.AmountMinor, Currency = payment.Currency, InitialSecondsCharge = plan.SecondsCharge, RemainChargeSec = plan.SecondsCharge, StartsAt = now, ExpiresAt = now.AddMonths(durationMonths), AutoRenew = false, PaymentId = payment.Id, }); } private static bool VerifyStripeSignature(string payload, string header, string secret) { // Stripe header: t=,v1=[,...] if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(header)) return false; var ts = ""; var sig = ""; foreach (var part in header.Split(',')) { if (part.StartsWith("t=")) ts = part[2..]; if (part.StartsWith("v1=")) sig = part[3..]; } if (string.IsNullOrEmpty(ts) || string.IsNullOrEmpty(sig)) return false; var message = $"{ts}.{payload}"; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expectedBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); var expected = Convert.ToHexString(expectedBytes).ToLowerInvariant(); return expected == sig; } private static PaymentResponse MapPaymentResponse(Payment p) => new( p.Id, p.Gateway.ToString(), p.Status.ToString(), p.Action.ToString(), p.AmountMinor, p.Currency, p.Title, p.Description, p.CardLast4, p.ConfirmedAt, p.FailedAt, p.FailureReason, p.CreatedAt ); }