From c2f5a9fdc0d4c7f48333004ab34db6f38d443134 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 19:02:58 +0000 Subject: [PATCH] feat: add Revenue Stream fees APIs Co-authored-by: myles.foster --- tests/FeesTest.cs | 189 +++++++++++++++++++++++++++++++++++++++++ trolley/FeesGateway.cs | 49 +++++++++++ trolley/Gateway.cs | 2 + trolley/Types/Fees.cs | 161 +++++++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 tests/FeesTest.cs create mode 100644 trolley/FeesGateway.cs create mode 100644 trolley/Types/Fees.cs diff --git a/tests/FeesTest.cs b/tests/FeesTest.cs new file mode 100644 index 0000000..b07bb64 --- /dev/null +++ b/tests/FeesTest.cs @@ -0,0 +1,189 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Trolley.Types; + +namespace tests +{ + [TestClass] + public class FeesTest + { + [TestMethod] + public void SearchRoutePricingPostsRequestAndDeserializesResponse() + { + string method = null; + string path = null; + string body = null; + + WithServer( + async context => + { + method = context.Request.HttpMethod; + path = context.Request.RawUrl; + body = await ReadBody(context); + Respond(context, "{\"ok\":true,\"fees\":[{\"currencyCode\":\"USD\",\"revenueStream\":{\"bankTransfer\":{\"ach\":{\"currencyCode\":\"USD\",\"integration\":\"bank-transfer\",\"routeType\":\"ach\",\"merchantAmountType\":\"percentage\",\"merchantAmount\":\"8.00\",\"guardrails\":{\"fixed\":{\"min\":\"0.00\",\"max\":\"2.00\"},\"percentage\":{\"min\":\"0.00\",\"max\":\"10.00\"}},\"percentCap\":{\"min\":null,\"max\":\"75.00\"},\"revenueShareId\":null}}}}]}"); + }, + gateway => + { + RoutePricingSearchResponse response = gateway.fees.SearchRoutePricing( + new RoutePricingSearchRequest { currency = "USD" }); + + Assert.AreEqual("USD", response.fees[0].currencyCode); + RevenueStream route = response.fees[0].revenueStream["bankTransfer"]["ach"]; + Assert.AreEqual("8.00", route.merchantAmount); + Assert.AreEqual("2.00", route.guardrails.fixedAmount.max); + Assert.IsNull(route.percentCap.min); + Assert.IsNull(route.revenueShareId); + }); + + Assert.AreEqual("POST", method); + Assert.AreEqual("/v1/fees/route-pricing/search", path); + Assert.AreEqual("USD", JObject.Parse(body)["currency"]); + } + + [TestMethod] + public void CreateRevenueStreamPostsRequestAndDeserializesResponse() + { + string path = null; + string body = null; + + WithServer( + async context => + { + path = context.Request.RawUrl; + body = await ReadBody(context); + Respond(context, "{\"ok\":true,\"revenueStream\":{\"currencyCode\":\"USD\",\"integration\":\"bank-transfer\",\"routeType\":\"ach\",\"merchantAmountType\":\"percentage\",\"merchantAmount\":\"8.00\",\"percentCap\":{\"min\":\"2.00\",\"max\":\"75.00\"},\"revenueShareId\":\"RSRC-1\"}}"); + }, + gateway => + { + RevenueStreamResponse response = gateway.fees.CreateRevenueStream( + new CreateRevenueStreamRequest + { + currencyCode = "USD", + integration = "bank-transfer", + routeType = "ach", + merchantAmountType = "percentage", + merchantAmount = "8.00", + minPercentCapAmount = "2.00", + maxPercentCapAmount = "75.00" + }); + + Assert.AreEqual("RSRC-1", response.revenueStream.revenueShareId); + Assert.AreEqual("75.00", response.revenueStream.percentCap.max); + }); + + Assert.AreEqual("/v1/fees/revenue-stream/create", path); + JObject request = JObject.Parse(body); + Assert.AreEqual("8.00", request["merchantAmount"]); + Assert.AreEqual("2.00", request["minPercentCapAmount"]); + } + + [TestMethod] + public void UpdateRevenueStreamPreservesOmittedNullAndValueCapStates() + { + var omitted = new UpdateRevenueStreamRequest(); + JObject omittedJson = JObject.Parse(omitted.ToJson()); + Assert.IsFalse(omittedJson.ContainsKey("minPercentCapAmount")); + Assert.IsFalse(omittedJson.ContainsKey("maxPercentCapAmount")); + + var explicitStates = new UpdateRevenueStreamRequest + { + minPercentCapAmount = null, + maxPercentCapAmount = "50.00" + }; + JObject explicitJson = JObject.Parse(explicitStates.ToJson()); + Assert.IsTrue(explicitJson.ContainsKey("minPercentCapAmount")); + Assert.AreEqual(JTokenType.Null, explicitJson["minPercentCapAmount"].Type); + Assert.AreEqual("50.00", explicitJson["maxPercentCapAmount"]); + } + + [TestMethod] + public void UpdateRevenueStreamPostsExplicitNullCap() + { + string path = null; + string body = null; + + WithServer( + async context => + { + path = context.Request.RawUrl; + body = await ReadBody(context); + Respond(context, "{\"ok\":true,\"revenueStream\":{\"revenueShareId\":\"RSRC-2\",\"percentCap\":{\"min\":null,\"max\":\"50.00\"}}}"); + }, + gateway => + { + RevenueStreamResponse response = gateway.fees.UpdateRevenueStream( + new UpdateRevenueStreamRequest + { + revenueShareId = "RSRC-1", + currencyCode = "USD", + integration = "bank-transfer", + routeType = "ach", + merchantAmountType = "percentage", + merchantAmount = "9.00", + minPercentCapAmount = null, + maxPercentCapAmount = "50.00" + }); + + Assert.AreEqual("RSRC-2", response.revenueStream.revenueShareId); + }); + + Assert.AreEqual("/v1/fees/revenue-stream/update", path); + JObject request = JObject.Parse(body); + Assert.IsTrue(request.ContainsKey("minPercentCapAmount")); + Assert.AreEqual(JTokenType.Null, request["minPercentCapAmount"].Type); + Assert.AreEqual("50.00", request["maxPercentCapAmount"]); + } + + private static async Task ReadBody(HttpListenerContext context) + { + using (var reader = new System.IO.StreamReader(context.Request.InputStream, context.Request.ContentEncoding)) + { + return await reader.ReadToEndAsync(); + } + } + + private static void Respond(HttpListenerContext context, string json) + { + byte[] response = Encoding.UTF8.GetBytes(json); + context.Response.StatusCode = 200; + context.Response.ContentType = "application/json"; + context.Response.OutputStream.Write(response, 0, response.Length); + context.Response.Close(); + } + + private static void WithServer(Func handler, Action test) + { + int port = GetAvailablePort(); + string baseUrl = $"http://localhost:{port}"; + + using (var listener = new HttpListener()) + { + listener.Prefixes.Add(baseUrl + "/"); + listener.Start(); + + Task server = Task.Run(async () => + { + HttpListenerContext context = await listener.GetContextAsync(); + await handler(context); + }); + + test(new Trolley.Gateway("access", "secret", baseUrl)); + server.GetAwaiter().GetResult(); + } + } + + private static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + } +} diff --git a/trolley/FeesGateway.cs b/trolley/FeesGateway.cs new file mode 100644 index 0000000..c311cb8 --- /dev/null +++ b/trolley/FeesGateway.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; +using System; +using Trolley.Types; + +namespace Trolley +{ + public class FeesGateway + { + private readonly Gateway gateway; + + public FeesGateway(Gateway gateway) + { + this.gateway = gateway; + } + + public RoutePricingSearchResponse SearchRoutePricing(RoutePricingSearchRequest request = null) + { + if (request == null) + { + request = new RoutePricingSearchRequest(); + } + + string response = gateway.client.Post("/v1/fees/route-pricing/search", request); + return JsonConvert.DeserializeObject(response); + } + + public RevenueStreamResponse CreateRevenueStream(CreateRevenueStreamRequest request) + { + if (request == null) + { + throw new MissingFieldException("revenue stream request can not be null."); + } + + string response = gateway.client.Post("/v1/fees/revenue-stream/create", request); + return JsonConvert.DeserializeObject(response); + } + + public RevenueStreamResponse UpdateRevenueStream(UpdateRevenueStreamRequest request) + { + if (request == null) + { + throw new MissingFieldException("revenue stream request can not be null."); + } + + string response = gateway.client.Post("/v1/fees/revenue-stream/update", request); + return JsonConvert.DeserializeObject(response); + } + } +} diff --git a/trolley/Gateway.cs b/trolley/Gateway.cs index 47c3ad2..ccfa947 100644 --- a/trolley/Gateway.cs +++ b/trolley/Gateway.cs @@ -15,6 +15,7 @@ public class Gateway public InvoicePaymentGateway invoicePayment; public VerificationGateway verification; public VerificationGateway trust; + public FeesGateway fees; public string apiKey; public string apiSecret; @@ -35,6 +36,7 @@ public Gateway(Configuration config) this.invoicePayment = new InvoicePaymentGateway(this); this.verification = new VerificationGateway(this); this.trust = this.verification; + this.fees = new FeesGateway(this); } public Gateway(string apiKey, string apiSecret, string apiBase = "production"):this(new Configuration(apiKey, apiSecret, apiBase)) diff --git a/trolley/Types/Fees.cs b/trolley/Types/Fees.cs new file mode 100644 index 0000000..5c2ec90 --- /dev/null +++ b/trolley/Types/Fees.cs @@ -0,0 +1,161 @@ +using Newtonsoft.Json; +using System.Collections.Generic; +using Trolley.JsonHelpers; + +namespace Trolley.Types +{ + public class RoutePricingSearchRequest : ITrolleyMappable + { + public string currency; + + public string ToJson() + { + return JsonConvert.SerializeObject(this, SerializerHelper.GetSerializerSettings()); + } + + public override string ToString() + { + return ToJson(); + } + + public bool IsMappable() + { + return true; + } + } + + public class CreateRevenueStreamRequest : ITrolleyMappable + { + public string currencyCode; + public string integration; + public string routeType; + public string merchantAmountType; + public string merchantAmount; + public string minPercentCapAmount; + public string maxPercentCapAmount; + + public string ToJson() + { + return JsonConvert.SerializeObject(this, SerializerHelper.GetSerializerSettings()); + } + + public override string ToString() + { + return ToJson(); + } + + public bool IsMappable() + { + return true; + } + } + + public class UpdateRevenueStreamRequest : ITrolleyMappable + { + public string revenueShareId; + public string currencyCode; + public string integration; + public string routeType; + public string merchantAmountType; + public string merchantAmount; + + private string minPercentCapAmountValue; + private string maxPercentCapAmountValue; + private bool minPercentCapAmountSpecified; + private bool maxPercentCapAmountSpecified; + + [JsonProperty( + NullValueHandling = NullValueHandling.Include, + DefaultValueHandling = DefaultValueHandling.Include)] + public string minPercentCapAmount + { + get { return minPercentCapAmountValue; } + set + { + minPercentCapAmountValue = value; + minPercentCapAmountSpecified = true; + } + } + + [JsonProperty( + NullValueHandling = NullValueHandling.Include, + DefaultValueHandling = DefaultValueHandling.Include)] + public string maxPercentCapAmount + { + get { return maxPercentCapAmountValue; } + set + { + maxPercentCapAmountValue = value; + maxPercentCapAmountSpecified = true; + } + } + + public bool ShouldSerializeminPercentCapAmount() + { + return minPercentCapAmountSpecified; + } + + public bool ShouldSerializemaxPercentCapAmount() + { + return maxPercentCapAmountSpecified; + } + + public string ToJson() + { + return JsonConvert.SerializeObject(this, SerializerHelper.GetSerializerSettings()); + } + + public override string ToString() + { + return ToJson(); + } + + public bool IsMappable() + { + return true; + } + } + + public class RoutePricingSearchResponse + { + public bool ok; + public List fees; + } + + public class RevenueStreamResponse + { + public bool ok; + public RevenueStream revenueStream; + } + + public class RoutePricingFee + { + public string currencyCode; + public Dictionary> revenueStream; + } + + public class RevenueStream + { + public string currencyCode; + public string integration; + public string routeType; + public string merchantAmountType; + public string merchantAmount; + public RevenueStreamGuardrails guardrails; + public AmountRange percentCap; + public string revenueShareId; + } + + public class RevenueStreamGuardrails + { + [JsonProperty("fixed")] + public AmountRange fixedAmount; + public AmountRange percentage; + } + + public class AmountRange + { + public string min; + public string max; + } +}