using System.Net.Http.Headers;
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
{
///
/// Stripe gateway implementation using raw HttpClient (no SDK).
/// BaseAddress for the named "Stripe" HttpClient: https://api.stripe.com/v1/
/// Authentication: Bearer token (Stripe secret key = ApiSecret).
/// Webhook signature: Stripe-Signature header format "t=timestamp,v1=sig".
/// Compute: HMAC-SHA256(webhookSecret, "{timestamp}.{rawPayload}").
/// Replay protection: reject events older than 300 seconds.
/// Amounts: Stripe uses smallest currency unit (e.g., paise/cents) — divide by 100 for display.
/// CreateOrderAsync returns a PaymentIntent — CheckoutUrl is null (frontend uses client_secret).
///
public sealed class StripeGateway : IPaymentGateway
{
private readonly HttpClient _http;
private readonly ILogger _logger;
private const int ReplayWindowSeconds = 300;
public string GatewayCode => "STRIPE";
public StripeGateway(HttpClient http, ILogger logger)
{
_http = http;
_logger = logger;
}
// ── Create Payment Intent ─────────────────────────────────────────────
public async Task CreateOrderAsync(
CreateGatewayOrderRequest req, CancellationToken ct)
{
try
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", req.ApiSecret);
// Stripe expects form-encoded body for v1 API
var formData = new Dictionary
{
["amount"] = ((long)(req.Amount * 100)).ToString(),
["currency"] = req.Currency.ToLowerInvariant(),
["metadata[pay_order_no]"] = req.PayOrderNo,
["metadata[pay_order_id]"] = req.PayOrderId.ToString(),
["automatic_payment_methods[enabled]"] = "true"
};
var content = new FormUrlEncodedContent(formData);
var response = await _http.PostAsync("payment_intents", content, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
_logger.LogError("Stripe CreatePaymentIntent HTTP {StatusCode}: {Body}", (int)response.StatusCode, errBody);
return new GatewayOrderResult(false, string.Empty, null,
$"Stripe 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 piId = root.GetProperty("id").GetString() ?? string.Empty;
// client_secret is used by the frontend — not a redirect URL
// CheckoutUrl = null; frontend reads client_secret from response metadata
return new GatewayOrderResult(true, piId, null, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "StripeGateway.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
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", req.ApiSecret);
var response = await _http.GetAsync(
$"payment_intents/{req.GatewayOrderId}", ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return new GatewayVerifyResult(false, "error", req.GatewayTransactionId, 0m,
$"Stripe 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 status = root.TryGetProperty("status", out var sEl) ? sEl.GetString() ?? "unknown" : "unknown";
var amount = root.TryGetProperty("amount", out var aEl) ? aEl.GetInt64() / 100m : 0m;
bool success = status == "succeeded";
return new GatewayVerifyResult(success, status, req.GatewayTransactionId, amount, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "StripeGateway.VerifyPaymentAsync failed for OrderId {OrderId}", req.GatewayOrderId);
return new GatewayVerifyResult(false, "error", req.GatewayTransactionId, 0m, ex.Message);
}
}
// ── Payout (Phase 2) ──────────────────────────────────────────────────
public Task InitiatePayoutAsync(PayoutRequest req, CancellationToken ct)
=> throw new NotImplementedException(
"Vendor payout — Phase 2. Use Stripe Payouts API with a connected account.");
// ── Parse & Verify Webhook ────────────────────────────────────────────
public Task ParseAndVerifyWebhookAsync(
string rawPayload, string sigHeader, string webhookSecret, CancellationToken ct)
{
try
{
// 1. Parse Stripe-Signature header: "t=1234567890,v1=abcdef...,v0=..."
long timestamp = 0;
string signatureV1 = 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))
signatureV1 = part[3..];
}
if (timestamp == 0 || string.IsNullOrEmpty(signatureV1))
{
_logger.LogWarning("Stripe webhook: malformed Stripe-Signature header");
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("Stripe webhook: replay attack detected. Age {AgeSeconds}s > {Window}s",
eventAgeSeconds, ReplayWindowSeconds);
return Task.FromResult(null);
}
// 3. Verify signature: HMAC-SHA256(secret, "{timestamp}.{rawPayload}")
var signedPayload = $"{timestamp}.{rawPayload}";
var computed = ComputeHmacSha256Hex(webhookSecret, signedPayload);
if (!string.Equals(computed, signatureV1, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Stripe webhook signature mismatch");
return Task.FromResult(null);
}
// 4. Parse event
using var doc = JsonDocument.Parse(rawPayload);
var root = doc.RootElement;
var eventType = root.TryGetProperty("type", out var tEl) ? tEl.GetString() ?? "" : "";
var eventId = root.TryGetProperty("id", out var iEl) ? iEl.GetString() ?? "" : "";
string piId = string.Empty;
decimal amount = 0m;
string status = string.Empty;
if (root.TryGetProperty("data", out var data) &&
data.TryGetProperty("object", out var obj))
{
piId = obj.TryGetProperty("id", out var pEl) ? pEl.GetString() ?? "" : "";
amount = obj.TryGetProperty("amount", out var aEl) ? aEl.GetInt64() / 100m : 0m;
status = obj.TryGetProperty("status", out var sEl) ? sEl.GetString() ?? "" : "";
}
var webhookEvent = new GatewayWebhookEvent(
IdempotencyKey: eventId,
EventType: eventType,
GatewayOrderId: piId,
GatewayTxnId: piId,
Status: status,
Amount: amount);
return Task.FromResult(webhookEvent);
}
catch (Exception ex)
{
_logger.LogError(ex, "StripeGateway.ParseAndVerifyWebhookAsync failed");
return Task.FromResult(null);
}
}
// ── Transaction Status ────────────────────────────────────────────────
public async Task GetTransactionStatusAsync(string gatewayTxnId, CancellationToken ct)
{
var response = await _http.GetAsync($"payment_intents/{gatewayTxnId}", 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);
var root = doc.RootElement;
var status = root.TryGetProperty("status", out var sEl) ? sEl.GetString() ?? "unknown" : "unknown";
var amount = root.TryGetProperty("amount", out var aEl) ? aEl.GetInt64() / 100m : 0m;
return new GatewayTransactionStatus(status, amount, null, null);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static string ComputeHmacSha256Hex(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.ToHexString(hash).ToLowerInvariant();
}
}
}