Skip to content
Merged
37 changes: 37 additions & 0 deletions .github/workflows/build-net-6.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Manual CI for .NET 6.0 legacy support.
# Not auto-triggered: We only maintain v6.0 support for older version compatibility.
name: "Legacy Support: .NET 6.0 Build & Test"

on:
workflow_dispatch:

permissions:
contents: read

jobs:
dotnet6-build-and-unit-test:
name: Build & Test (.NET 6.0) - ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]

steps:
- uses: actions/checkout@v4

- name: Setup .NET SDKs
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x

- name: Restore
run: dotnet restore

- name: Build (Debug)
run: dotnet build --configuration Debug --framework net6.0 --no-restore

- name: Test
run: dotnet test --no-build --configuration Debug --framework net6.0 --no-restore Adyen.Test/Adyen.Test.csproj
55 changes: 55 additions & 0 deletions Adyen.Test/StringEnumConverterTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System.Runtime.Serialization;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Adyen.Test
{
[TestClass]
public class StringEnumConverterTest
{
[JsonConverter(typeof(StringEnumConverter))]
private enum TestEnum
{
[EnumMember(Value = "Value1")]
Value1 = 1,

[EnumMember(Value = "Value2")]
Value2 = 2
}

private class TestModel
{
[JsonProperty("enumField")]
public TestEnum? EnumField { get; set; }
}

[TestMethod]
[ExpectedException(typeof(JsonSerializationException))]
public void Given_StringEnumConverter_When_UnknownValue_Throws_JsonSerializationException()
{
var json = @"{""enumField"": ""UnknownValue""}";
JsonConvert.DeserializeObject<TestModel>(json);
}

[TestMethod]
public void Given_StringEnumConverter_When_Null_Result_TestModel_EnumField_Is_Null()
{
var json = @"{""enumField"": null}";

var result = JsonConvert.DeserializeObject<TestModel>(json);
Assert.IsNull(result.EnumField);
}

[TestMethod]
public void Given_StringEnumConverter_When_Value1_Returns_Value1_As_Enum()
{
var json = @"{""enumField"": ""Value1""}";
var result = JsonConvert.DeserializeObject<TestModel>(json);

Assert.IsNotNull(result);
Assert.AreEqual(TestEnum.Value1, result.EnumField);
}
}
}
222 changes: 222 additions & 0 deletions Adyen.Test/UnknownEnumDeserializationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
using Adyen.Model.Checkout;
using Adyen.Model.TransferWebhooks;
using Adyen.Util;
using Adyen.Webhooks;
using Newtonsoft.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Adyen.Test
{
[TestClass]
public class UnknownEnumDeserializationTest
{
[TestMethod]
public void TestUnknownEnumValueDeserializesToNull()
{
// Test JSON with an unknown enum value
var json = @"{
""channel"": ""UnknownChannelValue"",
""amount"": {
""value"": 1000,
""currency"": ""EUR""
},
""reference"": ""test-ref"",
""merchantAccount"": ""test-merchant""
}";
Comment on lines +17 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using multi-line raw string literals for JSON can introduce unintended whitespace (like newlines and indentation) which might cause issues with some JSON parsers. For better readability and to ensure the JSON is correctly formatted, it's recommended to define test JSON on a single line. This makes the test less prone to formatting errors. This feedback applies to other small JSON string definitions in this test file. For larger JSON payloads like in TestTransferWebhookWithUnknownReasonEnum, consider moving them to an embedded resource file to keep the test code clean.

            var json = @"{\"channel\":\"UnknownChannelValue\",\"amount\":{\"value\":1000,\"currency\":\"EUR\"},\"reference\":\"test-ref\",\"merchantAccount\":\"test-merchant\"}";


// This should not throw an exception when using JsonOperation.Deserialize
var paymentRequest = JsonOperation.Deserialize<PaymentRequest>(json);

// The unknown enum value should deserialize to null
Assert.IsNotNull(paymentRequest);
Assert.IsNull(paymentRequest.Channel);
}

[TestMethod]
public void TestKnownEnumValueDeserializesCorrectly()
{
// Test JSON with a known enum value
var json = @"{
""channel"": ""Web"",
""amount"": {
""value"": 1000,
""currency"": ""EUR""
},
""reference"": ""test-ref"",
""merchantAccount"": ""test-merchant""
}";

var paymentRequest = JsonOperation.Deserialize<PaymentRequest>(json);

Assert.IsNotNull(paymentRequest);
Assert.IsNotNull(paymentRequest.Channel);
Assert.AreEqual(PaymentRequest.ChannelEnum.Web, paymentRequest.Channel);
}

[TestMethod]
public void TestMissingEnumValueDeserializesToNull()
{
// Test JSON without the enum field
var json = @"{
""amount"": {
""value"": 1000,
""currency"": ""EUR""
},
""reference"": ""test-ref"",
""merchantAccount"": ""test-merchant""
}";

var paymentRequest = JsonOperation.Deserialize<PaymentRequest>(json);

Assert.IsNotNull(paymentRequest);
Assert.IsNull(paymentRequest.Channel);
}

[TestMethod]
public void TestMultipleUnknownEnumValues()
{
// Test JSON with multiple unknown enum values
var json = @"{
""channel"": ""FutureChannel"",
""entityType"": ""UnknownEntityType"",
""industryUsage"": ""newUsageType"",
""amount"": {
""value"": 1000,
""currency"": ""EUR""
},
""reference"": ""test-ref"",
""merchantAccount"": ""test-merchant""
}";

var paymentRequest = JsonOperation.Deserialize<PaymentRequest>(json);

Assert.IsNotNull(paymentRequest);
Assert.IsNull(paymentRequest.Channel);
Assert.IsNull(paymentRequest.EntityType);
Assert.IsNull(paymentRequest.IndustryUsage);
}

[TestMethod]
public void TestSerializationOfNullEnumValue()
{
// Create a payment request with null enum values
var paymentRequest = new PaymentRequest
{
Channel = null,
EntityType = null
};

var json = JsonConvert.SerializeObject(paymentRequest);

// Null values should not be serialized (based on EmitDefaultValue = false)
Assert.IsFalse(json.Contains("\"channel\""));
Assert.IsFalse(json.Contains("\"entityType\""));
}

[TestMethod]
public void TestTransferWebhookWithUnknownReasonEnum()
{
var json = @"{
""data"": {
""balancePlatform"": ""N2F_SANDBOX"",
""creationDate"": ""2026-01-14T14:26:59+01:00"",
""id"": ""38EBJS69NWKG7GSL"",
""accountHolder"": {
""description"": ""AccountId : 9819 - LegalEntityId : 58019 - LegalEntityName : Sandbox 01"",
""id"": ""AH32CM322322995MS737SFD8T"",
""reference"": ""dd92c730-ae54-47c5-9c5f-25307e13efd4""
},
""amount"": { ""currency"": ""EUR"", ""value"": 100 },
""balanceAccount"": {
""description"": ""AccountId : 9819 - LegalEntityId : 58019 - LegalEntityName : Sandbox 01"",
""id"": ""BA32CM322322995MS737TFDPH"",
""reference"": ""dd92c730-ae54-47c5-9c5f-25307e13efd4""
},
""category"": ""issuedCard"",
""categoryData"": {
""authorisationType"": ""finalAuthorisation"",
""panEntryMode"": ""manual"",
""processingType"": ""ecommerce"",
""relayedAuthorisationData"": { ""metadata"": { ""RefusedByN2F"": ""RefusedByRelayedRules"" }, ""reference"": """" },
""schemeUniqueTransactionId"": ""MCS9S6YGY0114"",
""type"": ""issuedCard"",
""validationFacts"": [
{ ""result"": ""valid"", ""type"": ""accountLookup"" },
{ ""result"": ""valid"", ""type"": ""cardAuthentication"" },
{ ""result"": ""notValidated"", ""type"": ""mitAllowedMerchant"" },
{ ""result"": ""valid"", ""type"": ""paymentInstrumentFound"" },
{ ""result"": ""valid"", ""type"": ""transactionValidation"" },
{ ""result"": ""notApplicable"", ""type"": ""authorisedPaymentInstrumentUser"" },
{ ""result"": ""valid"", ""type"": ""screening"" },
{ ""result"": ""valid"", ""type"": ""partyScreening"" },
{ ""result"": ""valid"", ""type"": ""transactionRules"" },
{ ""result"": ""invalid"", ""type"": ""relayedAuthorisation"" },
{ ""result"": ""valid"", ""type"": ""inputExpiryDateCheck"" },
{ ""result"": ""valid"", ""type"": ""paymentInstrument"" },
{ ""result"": ""valid"", ""type"": ""cardholderAuthentication"" },
{ ""result"": ""valid"", ""type"": ""paymentInstrumentExpirationCheck"" },
{ ""result"": ""valid"", ""type"": ""balanceCheck"" },
{ ""result"": ""valid"", ""type"": ""paymentInstrumentActive"" },
{ ""result"": ""valid"", ""type"": ""realBalanceAvailable"" }
]
},
""counterparty"": {
""merchant"": {
""acquirerId"": ""013445"",
""mcc"": ""7999"",
""merchantId"": ""526567789012346"",
""nameLocation"": { ""city"": ""Amsterdam"", ""country"": ""NL"", ""name"": ""N2F_FR_SANDBOX_TEST"" },
""postalCode"": ""1011 DJ"",
""city"": ""Amsterdam"",
""country"": ""NETHERLANDS"",
""name"": ""N2F_FR_SANDBOX_TEST""
}
},
""createdAt"": ""2026-01-14T14:26:58+01:00"",
""direction"": ""outgoing"",
""paymentInstrument"": { ""id"": ""PI32CM722322B35NR3F6RBNN8"", ""reference"": ""dd92c730-ae54-47c5-9c5f-25307e13efd4"" },
""reason"": ""UNKNWON_REASON_HERE_000"",
""reference"": ""229BZZ4LB5LGGWS5-MCS9S6YGY"",
""status"": ""refused"",
""type"": ""payment"",
""balances"": [{ ""currency"": ""EUR"", ""received"": -100 }],
""eventId"": ""EVJN4295V223223W5NR3GPKDS32BBH"",
""events"": [
{
""amount"": { ""currency"": ""EUR"", ""value"": -100 },
""bookingDate"": ""2026-01-14T14:26:59+01:00"",
""id"": ""EVJN42CSX223223W5NR3GPKDNB4DBC"",
""mutations"": [{ ""currency"": ""EUR"", ""received"": -100 }],
""originalAmount"": { ""currency"": ""EUR"", ""value"": -100 },
""status"": ""received"",
""type"": ""accounting""
},
{
""amount"": { ""currency"": ""EUR"", ""value"": 100 },
""bookingDate"": ""2026-01-14T14:26:59+01:00"",
""id"": ""EVJN4295V223223W5NR3GPKDS32BBH"",
""mutations"": [{ ""currency"": ""EUR"", ""received"": 100 }],
""originalAmount"": { ""currency"": ""EUR"", ""value"": 100 },
""status"": ""refused"",
""type"": ""accounting""
}
],
""sequenceNumber"": 2,
""transactionRulesResult"": { ""allHardBlockRulesPassed"": true, ""score"": 0 },
""updatedAt"": ""2026-01-14T14:26:59+01:00""
},
""environment"": ""test"",
""timestamp"": ""2026-01-14T13:27:01.805Z"",
""type"": ""balancePlatform.transfer.updated""
}";

var handler = new BalancePlatformWebhookHandler();
var transferNotificationRequest = handler.GetGenericBalancePlatformWebhook(json) as TransferNotificationRequest;

Assert.IsNotNull(transferNotificationRequest);
Assert.IsNotNull(transferNotificationRequest.Data);
Assert.IsNull(transferNotificationRequest.Data.Reason); // Issue #1228
Assert.AreEqual(TransferData.StatusEnum.Refused, transferNotificationRequest.Data.Status);
}
}
}
33 changes: 33 additions & 0 deletions Adyen/Util/SafeStringEnumConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace Adyen.Util
{
/// <summary>
/// A custom JSON converter for enums that returns null for unknown values instead of throwing an exception.
/// This ensures forward compatibility when new enum values are added to the API.
/// </summary>
public class SafeStringEnumConverter : StringEnumConverter
{
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
try
{
return base.ReadJson(reader, objectType, existingValue, serializer);
}
catch (JsonSerializationException)
{
// If the enum value is unknown, return null for nullable enums
// This allows the application to continue processing even when encountering unknown enum values
if (Nullable.GetUnderlyingType(objectType) != null)
{
return null;
}

// For non-nullable enums, we still throw to maintain backward compatibility
throw;
}
}
}
}
10 changes: 5 additions & 5 deletions templates/csharp/modelGeneric.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
{{#deprecated}}
[Obsolete("{{#vendorExtensions.x-deprecatedInVersion}}Deprecated since {{#appName}}{{{.}}}{{/appName}} v{{#vendorExtensions.x-deprecatedInVersion}}{{.}}{{/vendorExtensions.x-deprecatedInVersion}}.{{/vendorExtensions.x-deprecatedInVersion}}{{#vendorExtensions.x-deprecatedMessage}} {{{.}}}{{/vendorExtensions.x-deprecatedMessage}}")]
{{/deprecated}}
public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} {{name}} { get; set; }
public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}?{{/isContainer}} {{name}} { get; set; }
{{#isReadOnly}}

/// <summary>
Expand All @@ -61,7 +61,7 @@
{{#deprecated}}
[Obsolete("{{#vendorExtensions.x-deprecatedInVersion}}Deprecated since {{#appName}}{{{.}}}{{/appName}} v{{#vendorExtensions.x-deprecatedInVersion}}{{.}}{{/vendorExtensions.x-deprecatedInVersion}}.{{/vendorExtensions.x-deprecatedInVersion}}{{#vendorExtensions.x-deprecatedMessage}} {{{.}}}{{/vendorExtensions.x-deprecatedMessage}}")]
{{/deprecated}}
public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} {{name}} { get; set; }
public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}?{{/isContainer}} {{name}} { get; set; }

/// <summary>
/// Returns false as {{name}} should not be serialized given that it's read-only.
Expand All @@ -78,7 +78,7 @@
{{#deprecated}}
[Obsolete("{{#vendorExtensions.x-deprecatedInVersion}}Deprecated since {{#appName}}{{{.}}}{{/appName}} v{{#vendorExtensions.x-deprecatedInVersion}}{{.}}{{/vendorExtensions.x-deprecatedInVersion}}.{{/vendorExtensions.x-deprecatedInVersion}}{{#vendorExtensions.x-deprecatedMessage}} {{{.}}}{{/vendorExtensions.x-deprecatedMessage}}")]
{{/deprecated}}
public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} {{name}}
public {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}?{{/isContainer}} {{name}}
{
get{ return _{{name}};}
set
Expand All @@ -87,7 +87,7 @@
_flag{{name}} = true;
}
}
private {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}} _{{name}};
private {{{complexType}}}{{^complexType}}{{{datatypeWithEnum}}}{{/complexType}}{{^isContainer}}?{{/isContainer}} _{{name}};
private bool _flag{{name}};

/// <summary>
Expand Down Expand Up @@ -128,7 +128,7 @@
{{#hasOnlyReadOnly}}
[JsonConstructorAttribute]
{{/hasOnlyReadOnly}}
public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isNumeric}}?{{/isNumeric}}{{#isBoolean}}{{^isNullable}}?{{/isNullable}}{{/isBoolean}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}} {{#lambda.camelcase_param}}{{name}}{{/lambda.camelcase_param}} = {{#defaultValue}}{{^isDateTime}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default({{{datatypeWithEnum}}}){{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default({{{datatypeWithEnum}}}{{#isNumeric}}?{{/isNumeric}}{{#isBoolean}}{{^isNullable}}?{{/isNullable}}{{/isBoolean}}{{#isEnum}}{{^isContainer}}{{^required}}?{{/required}}{{/isContainer}}{{/isEnum}}){{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_param}}{{name}}{{/lambda.camelcase_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}}
public {{classname}}({{#readWriteVars}}{{{datatypeWithEnum}}}{{#isNumeric}}?{{/isNumeric}}{{#isBoolean}}{{^isNullable}}?{{/isNullable}}{{/isBoolean}}{{#isEnum}}{{^isContainer}}?{{/isContainer}}{{/isEnum}} {{#lambda.camelcase_param}}{{name}}{{/lambda.camelcase_param}} = {{#defaultValue}}{{^isDateTime}}{{{defaultValue}}}{{/isDateTime}}{{#isDateTime}}default({{{datatypeWithEnum}}}){{/isDateTime}}{{/defaultValue}}{{^defaultValue}}default({{{datatypeWithEnum}}}{{#isNumeric}}?{{/isNumeric}}{{#isBoolean}}{{^isNullable}}?{{/isNullable}}{{/isBoolean}}{{#isEnum}}{{^isContainer}}?{{/isContainer}}{{/isEnum}}){{/defaultValue}}{{^-last}}, {{/-last}}{{/readWriteVars}}){{#parent}} : base({{#parentVars}}{{#lambda.camelcase_param}}{{name}}{{/lambda.camelcase_param}}{{^-last}}, {{/-last}}{{/parentVars}}){{/parent}}
{
{{#vars}}
{{^isInherited}}
Expand Down