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);
}
}
}