using System.Security.Cryptography; using System.Text; using System.Text.Json; using GoodBooks.PAY.PAYBLL.Abstractions; using Microsoft.Extensions.Logging; namespace GoodBooks.PAY.PAYBLL.Providers { /// /// Cashfree Payment Gateway (PG API v3) implementation using raw HttpClient (no SDK) — /// mirrors RazorpayGateway/StripeGateway's shape and conventions exactly. /// /// BaseAddress for the named "Cashfree" HttpClient: https://api.cashfree.com/pg/ /// Authentication: x-client-id / x-client-secret headers (ApiKey/ApiSecret respectively), /// plus a fixed x-api-version header — NOT Basic Auth or Bearer, unlike Razorpay/Stripe. /// CreateOrderAsync returns a `payment_session_id` — CheckoutUrl is built as Cashfree's /// documented Hosted Checkout link (https://payments.cashfree.com/order/#{session_id}), /// matching the pattern the frontend would otherwise construct via the Cashfree JS SDK. /// Webhook signature: Cashfree's real webhook uses TWO separate headers /// (x-webhook-signature, x-webhook-timestamp) — there is no single combined header the /// way Razorpay/Stripe send one. ReceiveWebhook.cs (PAYSL) combines both into the same /// "t=...,v1=..." compound-string convention StripeGateway already parses, so this class /// can reuse that exact parsing shape without any IPaymentGateway interface change. /// Compute: Base64(HMAC-SHA256(webhookSecret, "{timestamp}{rawPayload}")) — Cashfree's /// signature is Base64-encoded, NOT hex like Razorpay/Stripe. /// Replay protection: reject events older than 300 s, same window Stripe uses. /// Amounts: Cashfree uses whole currency units (rupees, not paise) — no /100 conversion. /// /// NOT YET IMPLEMENTED: InitiatePayoutAsync — vendor payout is Phase 2 for every gateway in /// this module (Razorpay/Stripe are identically unimplemented); Cashfree Payouts is a /// separate product (api.cashfree.com/payout/) with its own auth/Fund-Account flow. /// public sealed class CashfreeGateway : IPaymentGateway { private readonly HttpClient _http; private readonly ILogger _logger; private const int ReplayWindowSeconds = 300; private const string ApiVersion = "2023-08-01"; public string GatewayCode => "CASHFREE"; public CashfreeGateway(HttpClient http, ILogger logger) { _http = http; _logger = logger; } // ── Create Order ────────────────────────────────────────────────────── public async Task CreateOrderAsync( CreateGatewayOrderRequest req, CancellationToken ct) { try { ApplyAuthHeaders(req.ApiKey, req.ApiSecret); var body = new { order_id = req.PayOrderNo, order_amount = req.Amount, order_currency = req.Currency, customer_details = new { customer_id = req.PayOrderId.ToString(), customer_email = req.CustomerEmail, customer_phone = req.CustomerPhone }, order_meta = new { return_url = req.CallbackUrl } }; var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); var response = await _http.PostAsync("orders", content, ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var errBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _logger.LogError("Cashfree CreateOrder HTTP {StatusCode}: {Body}", (int)response.StatusCode, errBody); return new GatewayOrderResult(false, string.Empty, null, $"Cashfree error {(int)response.StatusCode}: {errBody}"); } var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var orderId = root.TryGetProperty("order_id", out var oEl) ? oEl.GetString() ?? string.Empty : string.Empty; var paymentSessionId = root.TryGetProperty("payment_session_id", out var sEl) ? sEl.GetString() : null; var checkoutUrl = paymentSessionId is not null ? $"https://payments.cashfree.com/order/#{paymentSessionId}" : null; return new GatewayOrderResult(true, orderId, checkoutUrl, null); } catch (Exception ex) { _logger.LogError(ex, "CashfreeGateway.CreateOrderAsync failed for PayOrderNo {PayOrderNo}", req.PayOrderNo); return new GatewayOrderResult(false, string.Empty, null, ex.Message); } } // ── Verify Payment ──────────────────────────────────────────────────── public async Task VerifyPaymentAsync( VerifyPaymentRequest req, CancellationToken ct) { try { ApplyAuthHeaders(req.ApiKey, req.ApiSecret); // Cashfree has no single "GET /payments/{id}" — payment attempts are listed // per order; the most recent attempt is what we care about here. var response = await _http.GetAsync( $"orders/{req.GatewayOrderId}/payments", ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var errBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); return new GatewayVerifyResult(false, "error", req.GatewayTransactionId, 0m, $"Cashfree error {(int)response.StatusCode}: {errBody}"); } var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) return new GatewayVerifyResult(false, "not_found", req.GatewayTransactionId, 0m, "No payment attempts found for this order."); var latest = doc.RootElement[0]; var status = latest.TryGetProperty("payment_status", out var stEl) ? stEl.GetString() ?? "unknown" : "unknown"; var amount = latest.TryGetProperty("payment_amount", out var amEl) ? amEl.GetDecimal() : 0m; return new GatewayVerifyResult( status.Equals("SUCCESS", StringComparison.OrdinalIgnoreCase), status, req.GatewayTransactionId, amount, null); } catch (Exception ex) { _logger.LogError(ex, "CashfreeGateway.VerifyPaymentAsync failed for OrderId {OrderId}", req.GatewayOrderId); return new GatewayVerifyResult(false, "error", req.GatewayTransactionId, 0m, ex.Message); } } // ── Payout (Phase 2 — same as Razorpay/Stripe) ──────────────────────── public Task InitiatePayoutAsync(PayoutRequest req, CancellationToken ct) => throw new NotImplementedException( "Vendor payout — Phase 2. Cashfree Payouts is a separate product/API " + "(api.cashfree.com/payout/) with its own Beneficiary-registration flow."); // ── Parse & Verify Webhook ──────────────────────────────────────────── public Task ParseAndVerifyWebhookAsync( string rawPayload, string sigHeader, string webhookSecret, CancellationToken ct) { try { // 1. Parse the "t=...,v1=..." compound string ReceiveWebhook.cs (PAYSL) builds // from Cashfree's two real headers (x-webhook-timestamp, x-webhook-signature). long timestamp = 0; string signatureB64 = string.Empty; foreach (var part in sigHeader.Split(',')) { if (part.StartsWith("t=", StringComparison.OrdinalIgnoreCase)) long.TryParse(part[2..], out timestamp); else if (part.StartsWith("v1=", StringComparison.OrdinalIgnoreCase)) signatureB64 = part[3..]; } if (timestamp == 0 || string.IsNullOrEmpty(signatureB64)) { _logger.LogWarning("Cashfree webhook: malformed/missing signature+timestamp headers"); return Task.FromResult(null); } // 2. Replay protection — reject events older than 300 s var eventAgeSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp; if (Math.Abs(eventAgeSeconds) > ReplayWindowSeconds) { _logger.LogWarning("Cashfree webhook: replay attack detected. Age {AgeSeconds}s > {Window}s", eventAgeSeconds, ReplayWindowSeconds); return Task.FromResult(null); } // 3. Verify signature: Base64(HMAC-SHA256(secret, "{timestamp}{rawPayload}")) // — Cashfree concatenates with no separator, and encodes the digest as // Base64 (not hex, unlike Razorpay/Stripe). var signedPayload = $"{timestamp}{rawPayload}"; var computed = ComputeHmacSha256Base64(webhookSecret, signedPayload); if (!string.Equals(computed, signatureB64, StringComparison.Ordinal)) { _logger.LogWarning("Cashfree webhook signature mismatch"); return Task.FromResult(null); } // 4. Parse event — shape: { type, data: { order: {...}, payment: {...} } } using var doc = JsonDocument.Parse(rawPayload); var root = doc.RootElement; var eventType = root.TryGetProperty("type", out var tEl) ? tEl.GetString() ?? string.Empty : string.Empty; string orderId = string.Empty; string txnId = string.Empty; string status = string.Empty; decimal amount = 0m; if (root.TryGetProperty("data", out var data)) { if (data.TryGetProperty("order", out var order)) orderId = order.TryGetProperty("order_id", out var oEl) ? oEl.GetString() ?? string.Empty : string.Empty; if (data.TryGetProperty("payment", out var payment)) { txnId = payment.TryGetProperty("cf_payment_id", out var pEl) ? pEl.GetString() ?? string.Empty : string.Empty; status = payment.TryGetProperty("payment_status", out var sEl) ? sEl.GetString() ?? string.Empty : string.Empty; amount = payment.TryGetProperty("payment_amount", out var aEl) ? aEl.GetDecimal() : 0m; } } var webhookEvent = new GatewayWebhookEvent( IdempotencyKey: txnId, EventType: eventType, GatewayOrderId: orderId, GatewayTxnId: txnId, Status: status, Amount: amount); return Task.FromResult(webhookEvent); } catch (Exception ex) { _logger.LogError(ex, "CashfreeGateway.ParseAndVerifyWebhookAsync failed"); return Task.FromResult(null); } } // ── Transaction Status ──────────────────────────────────────────────── public async Task GetTransactionStatusAsync(string gatewayTxnId, CancellationToken ct) { // NOTE: matches Razorpay/Stripe's own existing shape — called without credentials // in this interface signature (see their identical comment); gatewayTxnId here is // treated as the Cashfree order_id, consistent with VerifyPaymentAsync above. var response = await _http.GetAsync($"orders/{gatewayTxnId}/payments", ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) return new GatewayTransactionStatus("unknown", 0m, null, null); var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) return new GatewayTransactionStatus("unknown", 0m, null, null); var latest = doc.RootElement[0]; var status = latest.TryGetProperty("payment_status", out var sEl) ? sEl.GetString() ?? "unknown" : "unknown"; var amount = latest.TryGetProperty("payment_amount", out var aEl) ? aEl.GetDecimal() : 0m; var method = latest.TryGetProperty("payment_group", out var mEl) ? mEl.GetString() : null; DateTime? paidAt = latest.TryGetProperty("payment_completion_time", out var pEl) && DateTime.TryParse(pEl.GetString(), out var dt) ? dt : null; return new GatewayTransactionStatus(status, amount, method, paidAt); } // ── Helpers ─────────────────────────────────────────────────────────── private void ApplyAuthHeaders(string clientId, string clientSecret) { _http.DefaultRequestHeaders.Remove("x-client-id"); _http.DefaultRequestHeaders.Remove("x-client-secret"); _http.DefaultRequestHeaders.Remove("x-api-version"); _http.DefaultRequestHeaders.Add("x-client-id", clientId); _http.DefaultRequestHeaders.Add("x-client-secret", clientSecret); _http.DefaultRequestHeaders.Add("x-api-version", ApiVersion); } private static string ComputeHmacSha256Base64(string secret, string data) { var keyBytes = Encoding.UTF8.GetBytes(secret); var dataBytes = Encoding.UTF8.GetBytes(data); using var hmac = new HMACSHA256(keyBytes); var hash = hmac.ComputeHash(dataBytes); return Convert.ToBase64String(hash); } } }