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