diff --git a/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs b/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs index 8fbf247152..31846445a0 100644 --- a/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs +++ b/src/Stripe.net/Infrastructure/JsonConverters/STJUnixDateTimeConverter.cs @@ -5,6 +5,62 @@ namespace Stripe.Infrastructure using System.Text.Json; using System.Text.Json.Serialization; + /// + /// A JsonConverterFactory for use with DateTime and DateTime? implementations + /// to ensure we return a correctly typed custom converter. + /// +#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 type, + JsonSerializerOptions options) + { + if (type == typeof(DateTime?)) + { + return new STJUnixNullableDateTimeConverterImpl(); + } + else + { + return new STJUnixDateTimeConverterImpl(); + } + } + } + +#pragma warning disable SA1402 // File may only contain a single type + internal class STJUnixNullableDateTimeConverterImpl : 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 +69,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. diff --git a/src/Stripe.net/Infrastructure/JsonConverters/SerializablePropertyCache.cs b/src/Stripe.net/Infrastructure/JsonConverters/SerializablePropertyCache.cs index bacfe7b40c..bd092c60e9 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 = options => default(JsonConverter); 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)) + { + var cvtGenericMethod = getConverterFromFactoryMethod.MakeGenericMethod(this.PropertyInfo.PropertyType); + customConverter = (Func>)cvtGenericMethod.Invoke(null, new object[] { this.CustomConverterType, this.PropertyInfo.PropertyType }); + } + else + { + var cvtGenericMethod = getConverterForTypeMethod.MakeGenericMethod(baseType, baseType.GenericTypeArguments[0]); + customConverter = _ => (JsonConverter)cvtGenericMethod.Invoke(null, new object[] { this.CustomConverterType }); + } } 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 = options => customConverter(options) ?? getDefaultConverter(options); } return this.getConverter(options); @@ -176,6 +185,20 @@ private static JsonConverter GetConverterForType(Type ct) return new JsonConverterAdapter((T)conv); } + private static Func> GetConverterFromFactory(Type tf, Type ct) + { + return options => + { + var conv = converterCache.GetOrAdd(ct, (key) => + { + var factory = (JsonConverterFactory)Activator.CreateInstance(tf); + return factory.CreateConverter(ct, options); + }); + + return new JsonConverterAdapter, TV>((JsonConverter)conv); + }; + } + private static Func> GetDefaultConverter(Type t) { return options => new JsonConverterAdapter, TV>((JsonConverter)options.GetConverter(t)); diff --git a/src/StripeTests/Infrastructure/JsonConverters/STJUnixDateTimeConverterTest.cs b/src/StripeTests/Infrastructure/JsonConverters/STJUnixDateTimeConverterTest.cs new file mode 100644 index 0000000000..e75efc82d2 --- /dev/null +++ b/src/StripeTests/Infrastructure/JsonConverters/STJUnixDateTimeConverterTest.cs @@ -0,0 +1,51 @@ +#if NET6_0_OR_GREATER +namespace StripeTests +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.Json; + using System.Text.Json.Serialization; + using Stripe; + using Stripe.Infrastructure; + using Xunit; + using STJS = System.Text.Json.Serialization; + + public class STJUnixDateTimeConverterTest : BaseStripeTest + { + private readonly STJUnixDateTimeConverter converter; + private readonly JsonSerializerOptions options; + + public STJUnixDateTimeConverterTest() + { + this.converter = new STJUnixDateTimeConverter(); + this.options = new JsonSerializerOptions(); + } + + [Fact] + public void Read_ValidUnixTimestampAsNumber() + { + var json = "{\n \"created_at\": 1640995200\n}"; + var obj = JsonSerializer.Deserialize(json); + + Assert.Equal(1640995200, obj.CreatedAt); + Assert.NotNull(obj.CreatedAt); + } + + [Fact] + public void Read_ValidNullUnixTimestamp() + { + var payload = "{\n \"id\": \"evt_1Rr1JvFtG20dLAMsr7rHdgc2\",\n \"object\": \"event\",\n \"account\": \"acct_1RmfQsFtG20dLAMs\",\n \"api_version\": \"2025-07-30.basil\",\n \"context\": \"acct_1RmfQsFtG20dLAMs\",\n \"created\": 1753986915,\n \"data\": {\n \"object\": {\n \"id\": \"sub_1Rr1JuFtG20dLAMseqyV6s6G\",\n \"object\": \"subscription\",\n \"application\": \"ca_SmZ5iAGaRQDDNPqhbcsigRQREqD0dLt1\",\n \"application_fee_percent\": null,\n \"automatic_tax\": {\n \"disabled_reason\": null,\n \"enabled\": false,\n \"liability\": null\n },\n \"billing_cycle_anchor\": 1753986914,\n \"billing_cycle_anchor_config\": null,\n \"billing_mode\": {\n \"type\": \"classic\",\n \"updated_at\": 0\n },\n \"billing_thresholds\": null,\n \"cancel_at\": null,\n \"cancel_at_period_end\": false,\n \"canceled_at\": null,\n \"cancellation_details\": {\n \"comment\": null,\n \"feedback\": null,\n \"reason\": null\n },\n \"collection_method\": \"charge_automatically\",\n \"created\": 1753986914,\n \"currency\": \"usd\",\n \"customer\": \"cus_SmZKVtb338KqvK\",\n \"days_until_due\": null,\n \"default_payment_method\": \"pm_1Rr0QcFtG20dLAMsU9KmfBDC\",\n \"default_source\": null,\n \"default_tax_rates\": [],\n \"description\": null,\n \"discounts\": [],\n \"ended_at\": null,\n \"invoice_settings\": {\n \"account_tax_ids\": null,\n \"issuer\": {\n \"account\": null,\n \"type\": \"self\"\n }\n },\n \"items\": {\n \"object\": \"list\",\n \"data\": [\n {\n \"id\": \"si_SmaUvBvBsIYUlI\",\n \"object\": \"subscription_item\",\n \"billing_thresholds\": null,\n \"created\": 1753986915,\n \"current_period_end\": 1756665314,\n \"current_period_start\": 1753986914,\n \"discounts\": [],\n \"metadata\": {},\n \"plan\": {\n \"id\": \"price_1Rr0BmFtG20dLAMsgqSmxAvZ\",\n \"object\": \"plan\",\n \"active\": true,\n \"amount\": 1167,\n \"amount_decimal\": 1167,\n \"billing_scheme\": \"per_unit\",\n \"created\": 1753982566,\n \"currency\": \"usd\",\n \"interval\": \"month\",\n \"interval_count\": 1,\n \"livemode\": false,\n \"metadata\": {\n \"3c_round_up\": \"0\",\n \"3c_zone_id\": \"1\",\n \"3c_agreement_id\": \"5\"\n },\n \"meter\": null,\n \"nickname\": \"Zone 1\",\n \"product\": \"3c_agreement_5\",\n \"tiers\": null,\n \"tiers_mode\": null,\n \"transform_usage\": null,\n \"trial_period_days\": null,\n \"usage_type\": \"licensed\"\n },\n \"price\": {\n \"id\": \"price_1Rr0BmFtG20dLAMsgqSmxAvZ\",\n \"object\": \"price\",\n \"active\": true,\n \"billing_scheme\": \"per_unit\",\n \"created\": 1753982566,\n \"currency\": \"usd\",\n \"currency_options\": null,\n \"custom_unit_amount\": null,\n \"livemode\": false,\n \"lookup_key\": null,\n \"metadata\": {\n \"3c_round_up\": \"0\",\n \"3c_zone_id\": \"1\",\n \"3c_agreement_id\": \"5\"\n },\n \"nickname\": \"Zone 1\",\n \"product\": \"3c_agreement_5\",\n \"recurring\": {\n \"interval\": \"month\",\n \"interval_count\": 1,\n \"meter\": null,\n \"trial_period_days\": null,\n \"usage_type\": \"licensed\"\n },\n \"tax_behavior\": \"unspecified\",\n \"tiers\": null,\n \"tiers_mode\": null,\n \"transform_quantity\": null,\n \"type\": \"recurring\",\n \"unit_amount\": 1167,\n \"unit_amount_decimal\": 1167\n },\n \"quantity\": 1,\n \"subscription\": \"sub_1Rr1JuFtG20dLAMseqyV6s6G\",\n \"tax_rates\": []\n }\n ],\n \"has_more\": false,\n \"url\": \"/v1/subscription_items?subscription=sub_1Rr1JuFtG20dLAMseqyV6s6G\"\n },\n \"latest_invoice\": \"in_1Rr1JvFtG20dLAMsdm4DSlKg\",\n \"livemode\": false,\n \"metadata\": {},\n \"next_pending_invoice_item_invoice\": null,\n \"on_behalf_of\": null,\n \"pause_collection\": null,\n \"payment_settings\": {\n \"payment_method_options\": null,\n \"payment_method_types\": null,\n \"save_default_payment_method\": null\n },\n \"pending_invoice_item_interval\": null,\n \"pending_setup_intent\": null,\n \"pending_update\": null,\n \"schedule\": \"sub_sched_1Rr1JuFtG20dLAMsgegakr2q\",\n \"start_date\": 1753986914,\n \"status\": \"active\",\n \"test_clock\": null,\n \"transfer_data\": null,\n \"trial_end\": null,\n \"trial_settings\": {\n \"end_behavior\": {\n \"missing_payment_method\": \"create_invoice\"\n }\n },\n \"trial_start\": null\n },\n \"previous_attributes\": null\n },\n \"livemode\": false,\n \"pending_webhooks\": 4,\n \"request\": {\n \"id\": \"req_MKH4UX85vdDnq3\",\n \"idempotency_key\": \"caaec5d2-bf4d-4009-ab91-316a2e5394c4:283c3507-60c3-51e5-3df5-8b6f15f37ff6\"\n },\n \"type\": \"customer.subscription.created\"\n}"; + + // Fails + var des_json = JsonSerializer.Deserialize(payload); + } + + private class TestObject : StripeEntity + { + [STJS.JsonPropertyName("created_at")] + public long? CreatedAt { get; set; } + } + } +} +#endif