diff --git a/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs b/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs index 9f6fdcf577..d26f886b01 100644 --- a/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs +++ b/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs @@ -5,6 +5,66 @@ namespace Stripe.Infrastructure using System.Text.Json; using System.Text.Json.Serialization; + /// + /// A JsonConverterFactory that handles both DateTime and DateTime? types. + /// This factory creates specialized converters for each type to properly handle + /// null values for nullable DateTime properties (fixes issue #3157). + /// +#pragma warning disable SA1649 // File name should match first type name + internal class STJUnixDateTimeConverter : JsonConverterFactory +#pragma warning restore SA1649 // File name should match first type name + { + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(DateTime) || typeToConvert == typeof(DateTime?); + } + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert == typeof(DateTime?)) + { + return new STJUnixNullableDateTimeConverter(); + } + + return new STJUnixDateTimeConverterImpl(); + } + } + + /// + /// Converter for nullable DateTime? that properly handles null JSON values. + /// +#pragma warning disable SA1402 // File may only contain a single type + internal class STJUnixNullableDateTimeConverter : JsonConverter +#pragma warning restore SA1402 // File may only contain a single type + { + private static readonly STJUnixDateTimeConverterImpl BaseConverter = new STJUnixDateTimeConverterImpl(); + + /// + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + return BaseConverter.Read(ref reader, typeToConvert, options); + } + + /// + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + BaseConverter.Write(writer, value.Value, options); + } + } + /// /// Converts a to and from Unix epoch time. /// @@ -13,7 +73,9 @@ namespace Stripe.Infrastructure /// Newtonsoft.Json 11.0. Once we bump the minimum version of Newtonsoft.Json to 11.0, we can /// start using the provided converter and get rid of this class. /// - internal class STJUnixDateTimeConverter : JsonConverter +#pragma warning disable SA1402 // File may only contain a single type + internal class STJUnixDateTimeConverterImpl : JsonConverter +#pragma warning restore SA1402 // File may only contain a single type { /// /// Reads the JSON representation of the object. @@ -24,7 +86,6 @@ internal class STJUnixDateTimeConverter : JsonConverter /// The object value. public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - bool nullable = IsNullable(typeToConvert); long seconds; if (reader.TokenType == JsonTokenType.Number) @@ -73,16 +134,7 @@ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, Jso /// The calling serializer's options. public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) { - long seconds; - - if (value is DateTime dateTime) - { - seconds = (long)(dateTime.ToUniversalTime() - DateTimeUtils.UnixEpoch).TotalSeconds; - } - else - { - throw new JsonException("Expected date object value."); - } + long seconds = (long)(value.ToUniversalTime() - DateTimeUtils.UnixEpoch).TotalSeconds; if (seconds < 0) { @@ -91,21 +143,6 @@ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializer writer.WriteNumberValue(seconds); } - - private static bool IsNullable(Type t) - { - if (t == null) - { - throw new ArgumentNullException(nameof(t)); - } - - if (t.IsValueType) - { - return t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>); - } - - return true; - } } } #endif diff --git a/src/Stripe.net/Infrastructure/JsonConverters/SerializablePropertyCache.cs b/src/Stripe.net/Infrastructure/JsonConverters/SerializablePropertyCache.cs index bacfe7b40c..503c6df848 100644 --- a/src/Stripe.net/Infrastructure/JsonConverters/SerializablePropertyCache.cs +++ b/src/Stripe.net/Infrastructure/JsonConverters/SerializablePropertyCache.cs @@ -69,6 +69,7 @@ internal class SerializablePropertyInfo private static MethodInfo createGetDelegateMethod = typeof(SerializablePropertyInfo).GetMethod("CreateGetDelegate", BindingFlags.Static | BindingFlags.NonPublic); private static MethodInfo createSetDelegateMethod = typeof(SerializablePropertyInfo).GetMethod("CreateSetDelegate", BindingFlags.Static | BindingFlags.NonPublic); private static MethodInfo getConverterForTypeMethod = typeof(SerializablePropertyInfo).GetMethod("GetConverterForType", BindingFlags.Static | BindingFlags.NonPublic); + private static MethodInfo getConverterFromFactoryMethod = typeof(SerializablePropertyInfo).GetMethod("GetConverterFromFactory", BindingFlags.Static | BindingFlags.NonPublic); private static MethodInfo getDefaultConverterMethod = typeof(SerializablePropertyInfo).GetMethod("GetDefaultConverter", BindingFlags.Static | BindingFlags.NonPublic); private Func getDelegate = null; @@ -102,20 +103,28 @@ internal JsonConverter GetConverter(JsonSerializerOptions options) { if (this.getConverter == null) { - var customConverter = default(JsonConverter); + Func> customConverter = null; if (this.CustomConverterType != null) { - // this assumes any property-level JsonConverter attribute - // specifies a JsonConverter<> type and not a JsonConverterFactory - // type var baseType = this.CustomConverterType.BaseType; - var cvtGenericMethod = getConverterForTypeMethod.MakeGenericMethod(baseType, baseType.GenericTypeArguments[0]); - customConverter = (JsonConverter)cvtGenericMethod.Invoke(null, new object[] { this.CustomConverterType }); + if (baseType == typeof(JsonConverterFactory)) + { + // Handle JsonConverterFactory types (fixes issue #3157) + var cvtGenericMethod = getConverterFromFactoryMethod.MakeGenericMethod(this.PropertyInfo.PropertyType); + customConverter = (Func>)cvtGenericMethod.Invoke(null, new object[] { this.CustomConverterType, this.PropertyInfo.PropertyType }); + } + else + { + // Handle regular JsonConverter types + var cvtGenericMethod = getConverterForTypeMethod.MakeGenericMethod(baseType, baseType.GenericTypeArguments[0]); + var converter = (JsonConverter)cvtGenericMethod.Invoke(null, new object[] { this.CustomConverterType }); + customConverter = _ => converter; + } } var defaultCvtGenericMethod = getDefaultConverterMethod.MakeGenericMethod(this.PropertyInfo.PropertyType); var getDefaultConverter = (Func>)defaultCvtGenericMethod.Invoke(null, new object[] { this.PropertyInfo.PropertyType }); - this.getConverter = options => customConverter ?? getDefaultConverter(options); + this.getConverter = opts => customConverter != null ? customConverter(opts) : getDefaultConverter(opts); } return this.getConverter(options); @@ -180,6 +189,20 @@ private static Func> GetDefaultConv { return options => new JsonConverterAdapter, TV>((JsonConverter)options.GetConverter(t)); } + + private static Func> GetConverterFromFactory(Type factoryType, Type propertyType) + { + return options => + { + var conv = converterCache.GetOrAdd(propertyType, (key) => + { + var factory = (JsonConverterFactory)Activator.CreateInstance(factoryType); + return factory.CreateConverter(propertyType, options); + }); + + return new JsonConverterAdapter, TV>((JsonConverter)conv); + }; + } #pragma warning restore IDE0051 // Remove unused private members } diff --git a/src/StripeTests/Infrastructure/JsonConverters/STJUnixDateTimeConverterTest.cs b/src/StripeTests/Infrastructure/JsonConverters/STJUnixDateTimeConverterTest.cs new file mode 100644 index 0000000000..acea0df326 --- /dev/null +++ b/src/StripeTests/Infrastructure/JsonConverters/STJUnixDateTimeConverterTest.cs @@ -0,0 +1,229 @@ +#if NET6_0_OR_GREATER +namespace StripeTests +{ + using System; + using System.Text.Json; + using Stripe; + using Xunit; + + /// + /// Tests for . + /// These tests do NOT require stripe-mock as they test JSON deserialization directly. + /// + public class STJUnixDateTimeConverterTest + { + /// + /// Deserialization with valid Unix timestamp: Verifies the converter correctly + /// converts a valid Unix epoch timestamp to a DateTime value. + /// + [Fact] + public void Deserialize_WithValidUnixTimestamp_ReturnsCorrectDateTime() + { + // Unix timestamp: 1609459200 = 2021-01-01 00:00:00 UTC + var json = @"{""id"":""sub_123"",""object"":""subscription"",""canceled_at"":1609459200,""created"":1609459200}"; + + var subscription = JsonSerializer.Deserialize(json); + + Assert.NotNull(subscription); + Assert.True(subscription.CanceledAt.HasValue); + Assert.Equal(new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc), subscription.CanceledAt.Value); + } + + /// + /// Deserialization with null DateTime: Tests the fix for issue #3157. + /// A nullable DateTime? property should accept null values without throwing JsonException. + /// This test ensures the converter properly handles JsonTokenType.Null. + /// + [Fact] + public void Deserialize_WithNullDateTime_ShouldNotThrow() + { + // This is the bug from issue #3157 + // canceled_at is a DateTime? property, so null should be valid + var json = @"{""id"":""sub_123"",""object"":""subscription"",""canceled_at"":null,""created"":1609459200}"; + + // This should NOT throw JsonException + var subscription = JsonSerializer.Deserialize(json); + + Assert.NotNull(subscription); + Assert.Null(subscription.CanceledAt); + } + + /// + /// Multiple nullable DateTime fields with null values: Verifies the converter can handle + /// multiple DateTime? properties all with null values in a single object. + /// This is common for entities that have optional date fields (canceled_at, trial_end, etc.). + /// + [Fact] + public void Deserialize_MultipleNullableDateTimeFields_ShouldWork() + { + // Testing multiple nullable DateTime fields with null values + var json = @"{ + ""id"":""sub_123"", + ""object"":""subscription"", + ""canceled_at"":null, + ""trial_end"":null, + ""trial_start"":null, + ""cancel_at"":null, + ""ended_at"":null, + ""created"":1609459200, + ""current_period_end"":1612137600, + ""current_period_start"":1609459200 + }"; + + var subscription = JsonSerializer.Deserialize(json); + + Assert.NotNull(subscription); + Assert.Null(subscription.CanceledAt); + Assert.Null(subscription.TrialEnd); + Assert.Null(subscription.TrialStart); + Assert.Null(subscription.CancelAt); + Assert.Null(subscription.EndedAt); + + // Created is non-nullable DateTime, so it should have a value + Assert.NotEqual(default(DateTime), subscription.Created); + } + + /// + /// Mixed null and actual values: Verifies the converter correctly handles a mix of + /// null and non-null DateTime? fields in the same object. + /// + [Fact] + public void Deserialize_MixedNullAndValues_ShouldWork() + { + // Some DateTime? fields with null, others with values + var json = @"{ + ""id"":""sub_123"", + ""object"":""subscription"", + ""canceled_at"":1612137600, + ""trial_end"":null, + ""trial_start"":1609459200, + ""created"":1609459200 + }"; + + var subscription = JsonSerializer.Deserialize(json); + + Assert.NotNull(subscription); + Assert.True(subscription.CanceledAt.HasValue); + Assert.Null(subscription.TrialEnd); + Assert.True(subscription.TrialStart.HasValue); + } + + /// + /// Serialization with null DateTime: Verifies that when serializing an object with + /// a null DateTime? property, the converter outputs JSON null (not a number or empty value). + /// + [Fact] + public void Serialize_NullDateTime_WritesNull() + { + var subscription = new Subscription + { + Id = "sub_123", + CanceledAt = null, + Created = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + var json = JsonSerializer.Serialize(subscription); + + Assert.Contains("\"canceled_at\":null", json); + } + + /// + /// Serialization with DateTime value: Verifies that when serializing an object with + /// a non-null DateTime? property, the converter outputs the Unix epoch timestamp. + /// + [Fact] + public void Serialize_WithDateTime_WritesUnixTimestamp() + { + var subscription = new Subscription + { + Id = "sub_123", + CanceledAt = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Created = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + var json = JsonSerializer.Serialize(subscription); + + // 1609459200 = 2021-01-01 00:00:00 UTC + Assert.Contains("\"canceled_at\":1609459200", json); + } + + /// + /// Serialization then deserialization of null DateTime: Tests that the serialized null value + /// is correctly preserved through the serialization-deserialization cycle. + /// This test validates the converter produces correct JSON output for null values. + /// + [Fact] + public void RoundTrip_NullDateTime_SerializesCorrectly() + { + // First, deserialize a subscription with null canceled_at + var originalJson = @"{""id"":""sub_123"",""object"":""subscription"",""canceled_at"":null,""created"":1609459200}"; + var subscription = JsonSerializer.Deserialize(originalJson); + Assert.NotNull(subscription); + Assert.Null(subscription.CanceledAt); + + // Then serialize and verify the null is preserved in the output + var serializedJson = JsonSerializer.Serialize(subscription); + Assert.Contains("\"canceled_at\":null", serializedJson); + } + + /// + /// Serialization then deserialization of DateTime value: Tests that the serialized timestamp + /// is correctly preserved through the serialization-deserialization cycle. + /// + [Fact] + public void RoundTrip_WithDateTime_SerializesCorrectly() + { + // First, deserialize a subscription with a valid canceled_at timestamp + var originalJson = @"{""id"":""sub_123"",""object"":""subscription"",""canceled_at"":1609459200,""created"":1609459200}"; + var expectedDate = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var subscription = JsonSerializer.Deserialize(originalJson); + Assert.NotNull(subscription); + Assert.Equal(expectedDate, subscription.CanceledAt); + + // Then serialize and verify the timestamp is preserved in the output + var serializedJson = JsonSerializer.Serialize(subscription); + Assert.Contains("\"canceled_at\":1609459200", serializedJson); + } + + /// + /// Full Event payload from issue #3157: Real-world scenario where a nested Subscription object + /// within an Event payload contains null DateTime fields. This represents the exact scenario + /// reported in issue #3157 with a customer.subscription.created event. + /// + [Fact] + public void Deserialize_FullEventPayload_WithNullCanceledAt_ShouldWork() + { + // Real-world scenario from issue #3157 + var eventPayload = @"{ + ""id"": ""evt_123"", + ""object"": ""event"", + ""type"": ""customer.subscription.created"", + ""created"": 1609459200, + ""data"": { + ""object"": { + ""id"": ""sub_123"", + ""object"": ""subscription"", + ""canceled_at"": null, + ""created"": 1609459200, + ""current_period_end"": 1612137600, + ""current_period_start"": 1609459200 + } + } + }"; + + var evt = JsonSerializer.Deserialize(eventPayload); + + Assert.NotNull(evt); + Assert.Equal("evt_123", evt.Id); + + // The subscription should be deserialized correctly with null canceled_at + if (evt.Data?.Object is Subscription subscription) + { + Assert.Equal("sub_123", subscription.Id); + Assert.Null(subscription.CanceledAt); + } + } + } +} +#endif