From fa62db451658beba63b584e85d9be109dda8f0ce Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Wed, 10 Dec 2025 01:27:12 +1000 Subject: [PATCH 1/3] Added logging + fix of customer is not found issue. --- src/{ => Helpers}/IdempotencyKeyHelper.cs | 12 +- src/Helpers/LogHelper.cs | 100 +++++++++++++ src/{ => Helpers}/RequestHelper.cs | 5 +- src/Models/Customer/Customer.cs | 3 + src/Service/StripeRequest.cs | 169 ++++++++++++---------- src/StripeCheckout.cs | 64 +++++++- 6 files changed, 263 insertions(+), 90 deletions(-) rename src/{ => Helpers}/IdempotencyKeyHelper.cs (56%) create mode 100644 src/Helpers/LogHelper.cs rename src/{ => Helpers}/RequestHelper.cs (89%) diff --git a/src/IdempotencyKeyHelper.cs b/src/Helpers/IdempotencyKeyHelper.cs similarity index 56% rename from src/IdempotencyKeyHelper.cs rename to src/Helpers/IdempotencyKeyHelper.cs index 76c0081..d04efbf 100644 --- a/src/IdempotencyKeyHelper.cs +++ b/src/Helpers/IdempotencyKeyHelper.cs @@ -1,6 +1,6 @@ using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Service; -namespace Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout; +namespace Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Helpers; internal static class IdempotencyKeyHelper { @@ -9,14 +9,14 @@ public static string GetKey(ApiCommand command, string merchantName, string orde //Add new keys if needed. Idempotency keys could be used for any operations, except GET requests. return command switch { - ApiCommand.CreatePaymentIntent => GetKey(), - ApiCommand.CreatePaymentMethod => GetKey() + "PM", - ApiCommand.CreateSetupIntent => GetKey() + "SI", - ApiCommand.CreateSession => GetKey() + "SE", + ApiCommand.CreatePaymentIntent => GetKey("PI"), + ApiCommand.CreatePaymentMethod => GetKey("PM"), + ApiCommand.CreateSetupIntent => GetKey("SI"), + ApiCommand.CreateSession => GetKey("SE"), _ => string.Empty }; - string GetKey() => GetBaseKey(merchantName, orderId); + string GetKey(string prefix) => GetBaseKey(merchantName, orderId) + $"_{prefix}"; } private static string GetBaseKey(string merchantName, string orderId) => $"{merchantName}:{orderId}"; diff --git a/src/Helpers/LogHelper.cs b/src/Helpers/LogHelper.cs new file mode 100644 index 0000000..8bdbb4b --- /dev/null +++ b/src/Helpers/LogHelper.cs @@ -0,0 +1,100 @@ +using Dynamicweb.Logging; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Helpers; + +internal static class LogHelper +{ + public static void LogBuilderInitialization(StringBuilder logBuilder) + { + logBuilder.AppendLine(); + logBuilder.AppendLine("--- STRIPE CHECKOUT INTERACTION LOG ---"); + } + + public static void LogRequest(StringBuilder logBuilder, string link) + { + logBuilder.AppendLine(); + logBuilder.AppendLine("--- REQUEST ---"); + logBuilder.AppendLine(); + + logBuilder.AppendLine($"URL: {link}"); + } + + public static void LogRequestParameters(StringBuilder logBuilder, Dictionary parameters) + { + logBuilder.AppendLine(); + logBuilder.AppendLine("--- REQUEST DATA ---"); + logBuilder.AppendLine(); + + foreach ((string key, string value) in parameters) + { + string loggedValue = value; + if (key.Contains("secret", StringComparison.OrdinalIgnoreCase)) + loggedValue = "***HIDDEN***"; + + logBuilder.AppendLine($"{key} = '{loggedValue}'"); + } + } + + public static void LogResponse(StringBuilder logBuilder, HttpResponseMessage response, string responseText) + { + logBuilder.AppendLine(); + logBuilder.AppendLine("--- RESPONSE ---"); + logBuilder.AppendLine(); + + logBuilder.AppendLine($"HttpStatusCode: {response.StatusCode} ({response.ReasonPhrase})"); + logBuilder.AppendLine(); + + logBuilder.AppendLine($"Response Text: {responseText}"); + } + + public static void LogHttpRequestException(StringBuilder logBuilder, HttpRequestException requestException) + { + logBuilder.AppendLine(); + logBuilder.AppendLine($"--- EXCEPTION CAUGHT (HttpRequestException) ---"); + logBuilder.AppendLine(); + logBuilder.AppendLine($"Message: {requestException.Message}"); + if (requestException.StatusCode.HasValue) + logBuilder.AppendLine($"StatusCode: {requestException.StatusCode}"); + + logBuilder.AppendLine($"Stack Trace: {requestException.StackTrace}"); + } + + public static void LogErrorMessage(StringBuilder logBuilder, string errorMessage) + { + logBuilder.AppendLine(); + logBuilder.AppendLine("--- HTTP ERROR ---"); + logBuilder.AppendLine(); + + logBuilder.AppendLine(errorMessage); + } + + public static void LogArgumentException(StringBuilder logBuilder, ArgumentException argumentException) + { + logBuilder.AppendLine(); + logBuilder.AppendLine($"--- EXCEPTION CAUGHT (ArgumentException) ---"); + logBuilder.AppendLine(); + logBuilder.AppendLine($"Message: {argumentException.Message}"); + } + + public static void LogUnhandledException(StringBuilder logBuilder, Exception exception) + { + logBuilder.AppendLine(); + logBuilder.AppendLine($"--- UNEXPECTED EXCEPTION CAUGHT ({exception.GetType().Name}) ---"); + logBuilder.AppendLine(); + logBuilder.AppendLine($"Message: {exception.Message}"); + logBuilder.AppendLine($"Stack Trace: {exception.StackTrace}"); + } + + public static void FinalizeLog(StringBuilder logBuilder) + { + logBuilder.AppendLine(); + logBuilder.AppendLine("--- END OF INTERACTION ---"); + string message = logBuilder.ToString(); + + LogManager.System.GetLogger(LogCategory.Provider, typeof(StripeCheckout).FullName).Info(message); + } +} diff --git a/src/RequestHelper.cs b/src/Helpers/RequestHelper.cs similarity index 89% rename from src/RequestHelper.cs rename to src/Helpers/RequestHelper.cs index 08932b0..4f89709 100644 --- a/src/RequestHelper.cs +++ b/src/Helpers/RequestHelper.cs @@ -4,12 +4,11 @@ using System; using System.IO; using System.Text; -using System.Threading.Tasks; -namespace Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout; +namespace Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Helpers; internal static class RequestHelper -{ +{ public static bool IsAjaxRequest() { return "application/json".Equals(Context.Current.Request.Headers["Content-Type"], StringComparison.OrdinalIgnoreCase); diff --git a/src/Models/Customer/Customer.cs b/src/Models/Customer/Customer.cs index 6bf0322..0dd152f 100644 --- a/src/Models/Customer/Customer.cs +++ b/src/Models/Customer/Customer.cs @@ -9,6 +9,9 @@ internal sealed class Customer [DataMember(Name = "id")] public string Id { get; set; } + [DataMember(Name = "deleted")] + public bool Deleted { get; set; } + [DataMember(Name = "description")] public string Description { get; set; } diff --git a/src/Service/StripeRequest.cs b/src/Service/StripeRequest.cs index 39aea1e..3bfa560 100644 --- a/src/Service/StripeRequest.cs +++ b/src/Service/StripeRequest.cs @@ -1,10 +1,12 @@ using Dynamicweb.Core; +using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Helpers; using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Models.Error; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Threading.Tasks; namespace Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Service; @@ -18,89 +20,110 @@ internal static class StripeRequest public static string SendRequest(string secretKey, CommandConfiguration configuration) { - using (var messageHandler = GetMessageHandler()) - { - using (var client = new HttpClient(messageHandler)) - { - client.BaseAddress = new Uri(BaseAddress); - client.Timeout = new TimeSpan(0, 0, 0, 90); - if (!string.IsNullOrWhiteSpace(configuration.IdempotencyKey)) - client.DefaultRequestHeaders.Add("Idempotency-Key", configuration.IdempotencyKey); - - string apiCommand = GetCommandLink(configuration.CommandType, configuration.OperatorId, configuration.OperatorSecondId); - Task requestTask = configuration.CommandType switch - { - //POST - ApiCommand.CreateCustomer or - ApiCommand.UpdateCustomer or - ApiCommand.CreatePaymentIntent or - ApiCommand.CapturePaymentIntent or - ApiCommand.CancelPaymentIntent or - ApiCommand.CreateSetupIntent or - ApiCommand.ConfirmSetupIntent or - ApiCommand.CreatePaymentMethod or - ApiCommand.AttachPaymentMethod or - ApiCommand.DetachPaymentMethod or - ApiCommand.CreateRefund or - ApiCommand.CreateSession => client.PostAsync(apiCommand, new FormUrlEncodedContent(GetParameters(configuration.Parameters))), - //GET - ApiCommand.GetAllPaymentIntents or - ApiCommand.GetPaymentIntent or - ApiCommand.GetSetupIntent or - ApiCommand.GetPaymentMethod or - ApiCommand.GetCustomerPaymentMethod or - ApiCommand.GetCustomer or - ApiCommand.GetSession => client.GetAsync(apiCommand), - //DELETE - ApiCommand.DeleteCustomer => client.DeleteAsync(apiCommand), - _ => throw new NotSupportedException($"Unknown operation was used. The operation code: {configuration.CommandType}.") - }; - - try - { - using (HttpResponseMessage response = requestTask.GetAwaiter().GetResult()) - { - string data = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - - if (!response.IsSuccessStatusCode) - { - string errorMessage = "Unhandled exception. Operation failed."; - - var errorResponse = Converter.Deserialize(data); - if (errorResponse.Error is not null) - errorMessage = StripeService.GetErrorMessage(errorResponse.Error); - - throw new Exception(errorMessage); - } - - return data; - } - } - catch (HttpRequestException requestException) - { - throw new Exception($"An error occurred during Stripe request. Error code: {requestException.StatusCode}"); - } - } - } + var logBuilder = new StringBuilder(); + LogHelper.LogBuilderInitialization(logBuilder); + + using var messageHandler = GetMessageHandler(secretKey); + using var client = new HttpClient(messageHandler); + + client.BaseAddress = new Uri(BaseAddress); + client.Timeout = new TimeSpan(0, 0, 0, 90); + if (!string.IsNullOrWhiteSpace(configuration.IdempotencyKey)) + client.DefaultRequestHeaders.Add("Idempotency-Key", configuration.IdempotencyKey); + + string apiCommand = GetCommandLink(configuration.CommandType, configuration.OperatorId, configuration.OperatorSecondId); + LogHelper.LogRequest(logBuilder, apiCommand); - HttpMessageHandler GetMessageHandler() => new HttpClientHandler() + Task requestTask = configuration.CommandType switch { - AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, - PreAuthenticate = true, - Credentials = new NetworkCredential(secretKey, string.Empty) + //POST + ApiCommand.CreateCustomer or + ApiCommand.UpdateCustomer or + ApiCommand.CreatePaymentIntent or + ApiCommand.CapturePaymentIntent or + ApiCommand.CancelPaymentIntent or + ApiCommand.CreateSetupIntent or + ApiCommand.ConfirmSetupIntent or + ApiCommand.CreatePaymentMethod or + ApiCommand.AttachPaymentMethod or + ApiCommand.DetachPaymentMethod or + ApiCommand.CreateRefund or + ApiCommand.CreateSession => client.PostAsync(apiCommand, GetFormUrlEncodedContent(configuration, logBuilder)), + //GET + ApiCommand.GetAllPaymentIntents or + ApiCommand.GetPaymentIntent or + ApiCommand.GetSetupIntent or + ApiCommand.GetPaymentMethod or + ApiCommand.GetCustomerPaymentMethod or + ApiCommand.GetCustomer or + ApiCommand.GetSession => client.GetAsync(apiCommand), + //DELETE + ApiCommand.DeleteCustomer => client.DeleteAsync(apiCommand), + _ => throw new NotSupportedException($"Unknown operation was used. The operation code: {configuration.CommandType}.") }; - Dictionary GetParameters(Dictionary parameters) + try { - if (parameters?.Count is null or 0) - return new(); + using HttpResponseMessage response = requestTask.GetAwaiter().GetResult(); - Dictionary convertedParameters = parameters.ToDictionary(x => x.Key, y => parameters[y.Key]?.ToString() ?? string.Empty); + string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + LogHelper.LogResponse(logBuilder, response, responseText); + + if (!response.IsSuccessStatusCode) + { + string errorMessage = $"Unhandled exception. Operation failed: {response.ReasonPhrase}. Response text: {responseText}"; + + var errorResponse = Converter.Deserialize(responseText); + if (errorResponse.Error is not null) + errorMessage = StripeService.GetErrorMessage(errorResponse.Error); + + LogHelper.LogErrorMessage(logBuilder, errorMessage); + throw new Exception(errorMessage); + } + + return responseText; - return convertedParameters.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value)).ToDictionary(StringComparer.OrdinalIgnoreCase); + } + catch (HttpRequestException requestException) + { + LogHelper.LogHttpRequestException(logBuilder, requestException); + throw; + } + catch (Exception ex) + { + LogHelper.LogUnhandledException(logBuilder, ex); + throw; + } + finally + { + LogHelper.FinalizeLog(logBuilder); } } + private static FormUrlEncodedContent GetFormUrlEncodedContent(CommandConfiguration configuration, StringBuilder logBuilder) + { + if (configuration.Parameters?.Count is null or 0) + return null; + + Dictionary parameters = configuration.Parameters + .ToDictionary( + p => p.Key, + p => configuration.Parameters[p.Key]?.ToString() ?? string.Empty, + StringComparer.OrdinalIgnoreCase + ); + + LogHelper.LogRequestParameters(logBuilder, parameters); + + return new FormUrlEncodedContent(parameters); + } + + private static HttpMessageHandler GetMessageHandler(string secretKey) => new HttpClientHandler() + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, + PreAuthenticate = true, + Credentials = new NetworkCredential(secretKey, string.Empty) + }; + private static string GetCommandLink(ApiCommand command, string operatorId, string operatorSecondId) { return command switch diff --git a/src/StripeCheckout.cs b/src/StripeCheckout.cs index ea02bbb..4f354de 100644 --- a/src/StripeCheckout.cs +++ b/src/StripeCheckout.cs @@ -1,6 +1,8 @@ using Dynamicweb.Core; using Dynamicweb.Ecommerce.Cart; +using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Helpers; using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Models; +using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Models.Customer; using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Models.PaymentIntent; using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Models.PaymentMethod; using Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.Models.Refund; @@ -76,7 +78,7 @@ public string ErrorTemplate [AddInParameter("Capture now"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=Auto-captures a payment when it is authorized. Please note that it is illegal in some countries to capture payment before shipping any physical goods.;")] public bool CaptureNow { get; set; } - + // /// Gets or sets post mode indicates how user will be redirected to Stripe service /// @@ -85,7 +87,7 @@ public string PostModeSelection { get => PostMode.ToString(); set - { + { PostMode = value switch { nameof(PostModes.Auto) => PostModes.Auto, @@ -102,6 +104,9 @@ public string PostModeSelection [AddInParameter("Save cards"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=Allow Stripe to save payment methods data to use them in Dynamicweb (as saved cards). Works only for Card (Stripe payment method). You need to activate this setting to create recurring orders.")] public bool SaveCards { get; set; } = true; + [AddInParameter("Create new customer for new cards"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=When enabled, always creates a new Stripe customer for payments with new cards instead of trying to reuse existing customer data. This prevents issues with invalid or deleted customer records.")] + public bool CreateNewCustomerForNewCards { get; set; } + #endregion /// @@ -187,17 +192,16 @@ private OutputResult CreateSession(Order order, bool recurring) var cardSettings = new BasePaymentCardSettings(order); string action = recurring ? "CompleteSetup" : "Complete"; - PaymentCardToken savedCard = Services.PaymentCard.GetByUserId(order.CustomerAccessUserId).FirstOrDefault(card => !string.IsNullOrEmpty(card.Token)); - string[] cardToken = savedCard?.Token?.Split('|'); - var service = new StripeService(GetSecretKey()); + string customerId = GetCustomerId(order, service, null); + string idempotencyKey = IdempotencyKeyHelper.GetKey(ApiCommand.CreateSession, MerchantName, order.Id); Session session = service.CreateSession(idempotencyKey, order, new() { AutomaticCapture = CaptureNow, SavePaymentMethod = cardSettings.IsSaveNeeded && SaveCards, - CustomerId = cardToken?.ElementAtOrDefault(0), + CustomerId = customerId, Language = Language, Mode = recurring ? SessionMode.Setup : SessionMode.Payment, EmbeddedForm = PostMode is PostModes.InlineTemplate, @@ -222,6 +226,49 @@ private OutputResult CreateSession(Order order, bool recurring) } } + private string GetCustomerId(Order order, StripeService stripeService, PaymentCardToken savedCard) + { + // For saved cards (or new cards with disabled setting), we must use existing customer data + if (order.SavedCardId > 0 || !CreateNewCustomerForNewCards) + return GetExistingCustomerId(order, stripeService, savedCard); + + // If CreateNewCustomerForNewCards is enabled, we always create a new customer + return null; + } + + private string GetExistingCustomerId(Order order, StripeService stripeService, PaymentCardToken savedCard) + { + savedCard ??= Services.PaymentCard.GetByUserId(order.CustomerAccessUserId, order.PaymentMethodId) + .FirstOrDefault(card => !string.IsNullOrEmpty(card.Token)); + + string[] cardToken = savedCard?.Token?.Split('|'); + string customerId = cardToken?.ElementAtOrDefault(0); + + if (string.IsNullOrWhiteSpace(customerId)) + { + LogEvent(order, "No existing customer ID found for saved card"); + return null; + } + + try + { + Customer customer = stripeService.GetCustomer(customerId); + if (customer is null || customer.Deleted) + { + LogEvent(order, "Customer with id: {0} is not found or was deleted.", customerId); + return null; + } + + LogEvent(order, "Using existing Stripe customer: {0}", customerId); + return customer.Id; + } + catch (Exception ex) + { + LogError(order, ex, "Exception during retrieving customer with id: {0}.", customerId); + return null; + } + } + private void SavePaymentCard(Order order, PaymentMethod paymentMethod, BasePaymentCardSettings cardSettings) { if (cardSettings?.IsSaveNeeded is not true || !SaveCards) @@ -476,6 +523,7 @@ private OutputResult UseSavedCardInternal(Order order, bool recurringOrderPaymen try { var service = new StripeService(GetSecretKey()); + string customerId = GetCustomerId(order, service, savedCard); if (!order.IsRecurringOrderTemplate || recurringOrderPayment) { @@ -483,7 +531,7 @@ private OutputResult UseSavedCardInternal(Order order, bool recurringOrderPaymen PaymentIntent paymentIntent = service.CreateOffPaymentIntent(idempotencyKey, order, new() { AutomaticCapture = CaptureNow, - CustomerId = cardTokenKey.CustomerId, + CustomerId = customerId, PaymentMethodId = cardTokenKey.PaymentMethodId }); CompleteOrder(order, paymentIntent, null); @@ -494,7 +542,7 @@ private OutputResult UseSavedCardInternal(Order order, bool recurringOrderPaymen var idempotencyKey = IdempotencyKeyHelper.GetKey(ApiCommand.CreateSetupIntent, MerchantName, order.Id); SetupIntent setupIntent = service.CreateOffSetupIntent(idempotencyKey, new() { - CustomerId = cardTokenKey.CustomerId, + CustomerId = customerId, PaymentMethodId = cardTokenKey.PaymentMethodId }); if (setupIntent.Status is not PaymentIntentStatus.Succeeded) From 51b8c979f222d9e52321dd545fe606f1fcfdd95d Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Wed, 10 Dec 2025 01:27:38 +1000 Subject: [PATCH 2/3] Update version --- src/Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.csproj b/src/Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.csproj index 9f97258..3afda88 100644 --- a/src/Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.csproj +++ b/src/Dynamicweb.Ecommerce.CheckoutHandlers.StripeCheckout.csproj @@ -1,6 +1,6 @@  - 10.7.1 + 10.7.2 1.0.0.0 Stripe Checkout 2.0 Stripe Checkout handler From cdd61fc31c1c922365d1cd793ce2cca989685a9e Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Wed, 10 Dec 2025 02:37:28 +1000 Subject: [PATCH 3/3] Fixed the logger. --- src/Helpers/LogHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helpers/LogHelper.cs b/src/Helpers/LogHelper.cs index 8bdbb4b..d851aac 100644 --- a/src/Helpers/LogHelper.cs +++ b/src/Helpers/LogHelper.cs @@ -95,6 +95,6 @@ public static void FinalizeLog(StringBuilder logBuilder) logBuilder.AppendLine("--- END OF INTERACTION ---"); string message = logBuilder.ToString(); - LogManager.System.GetLogger(LogCategory.Provider, typeof(StripeCheckout).FullName).Info(message); + LogManager.Current.GetLogger(LogCategory.Provider, typeof(StripeCheckout).FullName).Log(message); } }