From 73cc17ea37382099bbefa50423ed48856ac9e8dc Mon Sep 17 00:00:00 2001 From: Greenfonts Date: Tue, 30 Jun 2026 09:20:34 +0100 Subject: [PATCH 1/3] feat: add card transactions client (charge, OTP, 3DS authorize) IMonnifyCardsClient covers direct card charges as an alternative to hosted checkout: ChargeAsync, AuthorizeOtpAsync, and the PCI DSS-gated Authorize3dsAsync. Registered with automatic retry disabled, same reasoning as Disbursements/Bills - ChargeAsync directly debits a card, so an ambiguous failure must be resolved via GetTransactionAsync rather than retried. Sandbox-tested with our documented test cards: the request shape is handled correctly (the dedicated "Failed Card" number gets its own distinct error), but the no-OTP/OTP/3DS cards all returned "Card bin not found" instead of completing a charge - reads as a sandbox account configuration gap rather than a request-format issue. See docs/COMPATIBILITY.md for details; AuthorizeOtpAsync's response shape and ChargeCardResult.TokenId remain unverified against a real OTP-required response as a result. --- CHANGELOG.md | 5 + docs/COMPATIBILITY.md | 3 + src/Monnify/Cards/IMonnifyCardsClient.cs | 37 ++++ .../Cards/Models/Authorize3dsCardRequest.cs | 20 +++ .../Cards/Models/AuthorizeCardOtpRequest.cs | 21 +++ .../Cards/Models/AuthorizeCardOtpResult.cs | 27 +++ src/Monnify/Cards/Models/Card.cs | 29 ++++ src/Monnify/Cards/Models/ChargeCardRequest.cs | 19 ++ src/Monnify/Cards/Models/ChargeCardResult.cs | 32 ++++ src/Monnify/Cards/Models/DeviceInformation.cs | 35 ++++ src/Monnify/Cards/MonnifyCardsClient.cs | 50 ++++++ src/Monnify/Http/MonnifyApiPaths.cs | 7 + src/Monnify/MonnifyClient.cs | 7 +- src/Monnify/ServiceCollectionExtensions.cs | 7 + .../Cards/MonnifyCardsClientTests.cs | 162 ++++++++++++++++++ 15 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/Monnify/Cards/IMonnifyCardsClient.cs create mode 100644 src/Monnify/Cards/Models/Authorize3dsCardRequest.cs create mode 100644 src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs create mode 100644 src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs create mode 100644 src/Monnify/Cards/Models/Card.cs create mode 100644 src/Monnify/Cards/Models/ChargeCardRequest.cs create mode 100644 src/Monnify/Cards/Models/ChargeCardResult.cs create mode 100644 src/Monnify/Cards/Models/DeviceInformation.cs create mode 100644 src/Monnify/Cards/MonnifyCardsClient.cs create mode 100644 tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fb3eb..451eb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ own version is independent of Monnify's API versioning. ## [Unreleased] +### Added +- `IMonnifyCardsClient` (`ChargeAsync`, `AuthorizeOtpAsync`, `Authorize3dsAsync`) for direct card + charges, exposed on `MonnifyClient` as `Cards`. Automatic retry disabled, same reasoning as + Disbursements/Bills. See docs/COMPATIBILITY.md for sandbox-verification notes. + ## [0.1.0] - 2026-06-29 ### Fixed diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 3547721..ec3aeda 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -56,6 +56,9 @@ Status legend: | `IMonnifyBillsClient.ValidateCustomerAsync` | `POST /api/v1/vas/bills-payment/validate-customer` | **Implemented** | `validationReference` lives inside `vendInstruction`, not top-level as our docs prose implies. `minAmount`/`maxAmount` can appear top-level for amount-constrained products | | `IMonnifyBillsClient.VendAsync` | `POST /api/v1/vas/bills-payment/vend` | **Implemented** | Automatic retry disabled, same reasoning as Disbursements | | `IMonnifyBillsClient.RequeryAsync` | `GET /api/v1/vas/bills-payment/requery?reference=` | **Implemented** | Same response shape as `VendAsync` | +| `IMonnifyCardsClient.ChargeAsync` | `POST /api/v1/merchant/cards/charge` | **Implemented** | Requires a `transactionReference` from `InitializeTransactionAsync` first. Automatic retry disabled - this directly debits a card | +| `IMonnifyCardsClient.AuthorizeOtpAsync` | `POST /api/v1/merchant/cards/otp/authorize` | **Implemented** | Not sandbox-verified - blocked on the `ChargeAsync` bin issue above, since this needs a real `tokenId` from a successful OTP-required charge | +| `IMonnifyCardsClient.Authorize3dsAsync` | `POST /api/v1/sdk/cards/secure-3d/authorize` | **Implemented** | Restricted to PCI DSS-certified merchants, requires separate activation. Calling it without a prior successful charge correctly returned `"No Card Payment found for this transaction."` rather than an auth/activation error, so the endpoint itself is reachable on this account; the OTP-required response shape is otherwise unverified for the same reason as `AuthorizeOtpAsync` | Update this table whenever a method moves from Planned to Implemented, or its endpoint changes. diff --git a/src/Monnify/Cards/IMonnifyCardsClient.cs b/src/Monnify/Cards/IMonnifyCardsClient.cs new file mode 100644 index 0000000..f6e4003 --- /dev/null +++ b/src/Monnify/Cards/IMonnifyCardsClient.cs @@ -0,0 +1,37 @@ +using Monnify.Http; + +namespace Monnify.Cards; + +/// +/// Direct card charges (raw PAN/CVV/PIN) against an already-initialized transaction, as an +/// alternative to the hosted checkout flow in . +/// +/// +/// This client is registered with automatic HTTP retry disabled, for the same reason as +/// and : +/// directly attempts to debit a card, so an ambiguous failure (timeout, +/// network error, 5xx) must not be blindly resent with the same details - that risks a double +/// charge. Query the transaction's status via +/// with the same +/// transactionReference instead. +/// +public interface IMonnifyCardsClient +{ + /// + /// Charges a card against a transaction reference obtained from + /// . Depending on + /// the card, the result is either immediately final, or requires a follow-up call to + /// (OTP) or (3DS) to complete. + /// + Task ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default); + + /// Completes a charge that required an OTP, using the token ID from 's response. + Task AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default); + + /// + /// Completes a charge on a card that uses 3D Secure authentication. This is not active by + /// default - it's restricted to PCI DSS-certified merchants and requires it to be enabled for + /// your account first. + /// + Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Monnify/Cards/Models/Authorize3dsCardRequest.cs b/src/Monnify/Cards/Models/Authorize3dsCardRequest.cs new file mode 100644 index 0000000..bdffc51 --- /dev/null +++ b/src/Monnify/Cards/Models/Authorize3dsCardRequest.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +public sealed class Authorize3dsCardRequest +{ + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + /// Your Monnify API key. Unlike every other call in this SDK, this endpoint's contract + /// puts it in the request body rather than relying solely on the bearer token. + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } = string.Empty; + + [JsonPropertyName("collectionChannel")] + public string CollectionChannel { get; set; } = "API_NOTIFICATION"; + + [JsonPropertyName("card")] + public Card Card { get; set; } = new(); +} diff --git a/src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs b/src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs new file mode 100644 index 0000000..8e97b27 --- /dev/null +++ b/src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +public sealed class AuthorizeCardOtpRequest +{ + /// The transaction reference from the ChargeAsync call. + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("collectionChannel")] + public string CollectionChannel { get; set; } = "API_NOTIFICATION"; + + /// The token ID returned by the ChargeAsync response for a card that requires OTP. + [JsonPropertyName("tokenId")] + public string TokenId { get; set; } = string.Empty; + + /// The OTP sent to the cardholder's device. + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; +} diff --git a/src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs b/src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs new file mode 100644 index 0000000..285b3b4 --- /dev/null +++ b/src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +/// Returned by both AuthorizeOtpAsync and Authorize3dsAsync - they share the same shape. +public sealed class AuthorizeCardOtpResult +{ + /// E.g. SUCCESSFUL. + [JsonPropertyName("paymentStatus")] + public string PaymentStatus { get; set; } = string.Empty; + + [JsonPropertyName("paymentDescription")] + public string PaymentDescription { get; set; } = string.Empty; + + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("paymentReference")] + public string PaymentReference { get; set; } = string.Empty; + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("amountPaid")] + public decimal AmountPaid { get; set; } + + [JsonPropertyName("currencyPaid")] + public string CurrencyPaid { get; set; } = string.Empty; +} diff --git a/src/Monnify/Cards/Models/Card.cs b/src/Monnify/Cards/Models/Card.cs new file mode 100644 index 0000000..2b42416 --- /dev/null +++ b/src/Monnify/Cards/Models/Card.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +/// +/// Raw card details for a direct charge. All fields are modeled as strings (rather than the +/// numeric types our docs show for some of these on the 3DS endpoint specifically) to avoid +/// silently dropping a leading zero in , , or +/// . +/// +public sealed class Card +{ + [JsonPropertyName("number")] + public string Number { get; set; } = string.Empty; + + /// Two digits, e.g. "01"-"12". + [JsonPropertyName("expiryMonth")] + public string ExpiryMonth { get; set; } = string.Empty; + + /// Four digits, e.g. "2025". + [JsonPropertyName("expiryYear")] + public string ExpiryYear { get; set; } = string.Empty; + + [JsonPropertyName("pin")] + public string Pin { get; set; } = string.Empty; + + [JsonPropertyName("cvv")] + public string Cvv { get; set; } = string.Empty; +} diff --git a/src/Monnify/Cards/Models/ChargeCardRequest.cs b/src/Monnify/Cards/Models/ChargeCardRequest.cs new file mode 100644 index 0000000..29ab0f7 --- /dev/null +++ b/src/Monnify/Cards/Models/ChargeCardRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +public sealed class ChargeCardRequest +{ + /// A transaction reference from a prior InitializeTransactionAsync call. + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("collectionChannel")] + public string CollectionChannel { get; set; } = "API_NOTIFICATION"; + + [JsonPropertyName("card")] + public Card Card { get; set; } = new(); + + [JsonPropertyName("deviceInformation")] + public DeviceInformation DeviceInformation { get; set; } = new(); +} diff --git a/src/Monnify/Cards/Models/ChargeCardResult.cs b/src/Monnify/Cards/Models/ChargeCardResult.cs new file mode 100644 index 0000000..c1ec619 --- /dev/null +++ b/src/Monnify/Cards/Models/ChargeCardResult.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +public sealed class ChargeCardResult +{ + /// E.g. SUCCESS for a card that doesn't require OTP/3DS. + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("paymentReference")] + public string PaymentReference { get; set; } = string.Empty; + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("authorizedAmount")] + public decimal AuthorizedAmount { get; set; } + + /// + /// Present when the card requires OTP - pass it to AuthorizeOtpAsync. Our only documented + /// sample response is for a card that doesn't need OTP, so this field doesn't appear there; its + /// existence and name come from the OTP-authorize endpoint's own request docs ("the token ID + /// from the charge card endpoint response"), not from a captured OTP-required sample. + /// + [JsonPropertyName("tokenId")] + public string? TokenId { get; set; } +} diff --git a/src/Monnify/Cards/Models/DeviceInformation.cs b/src/Monnify/Cards/Models/DeviceInformation.cs new file mode 100644 index 0000000..70c4607 --- /dev/null +++ b/src/Monnify/Cards/Models/DeviceInformation.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Cards; + +/// +/// Browser/device fingerprint sent alongside a card charge, used for fraud screening and 3DS. +/// Field names mirror the EMV 3-D Secure browser-data fields our docs use verbatim. +/// +public sealed class DeviceInformation +{ + [JsonPropertyName("httpBrowserLanguage")] + public string HttpBrowserLanguage { get; set; } = string.Empty; + + [JsonPropertyName("httpBrowserJavaEnabled")] + public bool HttpBrowserJavaEnabled { get; set; } + + [JsonPropertyName("httpBrowserJavaScriptEnabled")] + public bool HttpBrowserJavaScriptEnabled { get; set; } + + [JsonPropertyName("httpBrowserColorDepth")] + public int HttpBrowserColorDepth { get; set; } + + [JsonPropertyName("httpBrowserScreenHeight")] + public int HttpBrowserScreenHeight { get; set; } + + [JsonPropertyName("httpBrowserScreenWidth")] + public int HttpBrowserScreenWidth { get; set; } + + /// The browser's UTC offset in minutes, as a string. Our own documented sample leaves this empty. + [JsonPropertyName("httpBrowserTimeDifference")] + public string HttpBrowserTimeDifference { get; set; } = string.Empty; + + [JsonPropertyName("userAgentBrowserValue")] + public string UserAgentBrowserValue { get; set; } = string.Empty; +} diff --git a/src/Monnify/Cards/MonnifyCardsClient.cs b/src/Monnify/Cards/MonnifyCardsClient.cs new file mode 100644 index 0000000..d72bc99 --- /dev/null +++ b/src/Monnify/Cards/MonnifyCardsClient.cs @@ -0,0 +1,50 @@ +using Monnify.Http; + +namespace Monnify.Cards; + +internal sealed class MonnifyCardsClient : MonnifyHttpClientBase, IMonnifyCardsClient +{ + public MonnifyCardsClient(HttpClient httpClient) : base(httpClient) { } + + public Task ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Cards.Charge) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Cards.AuthorizeOtp) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Cards.Authorize3ds) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } +} diff --git a/src/Monnify/Http/MonnifyApiPaths.cs b/src/Monnify/Http/MonnifyApiPaths.cs index d4e99d1..ac9ef54 100644 --- a/src/Monnify/Http/MonnifyApiPaths.cs +++ b/src/Monnify/Http/MonnifyApiPaths.cs @@ -85,4 +85,11 @@ internal static class Bills public const string Requery = "/api/v1/vas/bills-payment/requery"; public const string ValidateCustomer = "/api/v1/vas/bills-payment/validate-customer"; } + + internal static class Cards + { + public const string Charge = "/api/v1/merchant/cards/charge"; + public const string AuthorizeOtp = "/api/v1/merchant/cards/otp/authorize"; + public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize"; + } } diff --git a/src/Monnify/MonnifyClient.cs b/src/Monnify/MonnifyClient.cs index 21ad72b..5020be2 100644 --- a/src/Monnify/MonnifyClient.cs +++ b/src/Monnify/MonnifyClient.cs @@ -1,5 +1,6 @@ using Monnify.Banks; using Monnify.Bills; +using Monnify.Cards; using Monnify.Collections; using Monnify.Disbursements; using Monnify.Verification; @@ -18,13 +19,15 @@ public MonnifyClient( IMonnifyVerificationClient verification, IMonnifyCollectionsClient collections, IMonnifyDisbursementsClient disbursements, - IMonnifyBillsClient bills) + IMonnifyBillsClient bills, + IMonnifyCardsClient cards) { Banks = banks; Verification = verification; Collections = collections; Disbursements = disbursements; Bills = bills; + Cards = cards; } public IMonnifyBanksClient Banks { get; } @@ -36,4 +39,6 @@ public MonnifyClient( public IMonnifyDisbursementsClient Disbursements { get; } public IMonnifyBillsClient Bills { get; } + + public IMonnifyCardsClient Cards { get; } } diff --git a/src/Monnify/ServiceCollectionExtensions.cs b/src/Monnify/ServiceCollectionExtensions.cs index 221f983..a9c53c1 100644 --- a/src/Monnify/ServiceCollectionExtensions.cs +++ b/src/Monnify/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Monnify.Authentication; using Monnify.Banks; using Monnify.Bills; +using Monnify.Cards; using Monnify.Collections; using Monnify.Disbursements; using Monnify.Http; @@ -61,6 +62,12 @@ public static IServiceCollection AddMonnify(this IServiceCollection services, Ac services.AddHttpClient(ConfigureBaseAddress) .AddMonnifyDefaults(allowAutomaticRetry: false); + // allowAutomaticRetry: false - ChargeAsync directly debits a card, same reasoning as + // Disbursements/Bills above: an ambiguous failure must be resolved by querying the + // transaction's status, not retried with the same card details. + services.AddHttpClient(ConfigureBaseAddress) + .AddMonnifyDefaults(allowAutomaticRetry: false); + services.AddTransient(); return services; diff --git a/tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs b/tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs new file mode 100644 index 0000000..5e617c3 --- /dev/null +++ b/tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using Monnify.Cards; +using Monnify.Exceptions; +using Monnify.Tests.TestUtilities; + +namespace Monnify.Tests.Cards; + +// Payloads below are from our own documented samples for these endpoints; see +// docs/COMPATIBILITY.md for sandbox-verification status once that's done. +public class MonnifyCardsClientTests +{ + private static MonnifyCardsClient CreateClient(FakeHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); + + private static ChargeCardRequest CreateChargeRequest(string cardNumber = "4111111111111111") => new() + { + TransactionReference = "MNFY|99|20220725125351|000271", + Card = new Card + { + Number = cardNumber, + ExpiryMonth = "10", + ExpiryYear = "2025", + Pin = "1234", + Cvv = "123", + }, + DeviceInformation = new DeviceInformation + { + HttpBrowserLanguage = "en-US", + HttpBrowserJavaEnabled = false, + HttpBrowserJavaScriptEnabled = true, + HttpBrowserColorDepth = 24, + HttpBrowserScreenHeight = 1203, + HttpBrowserScreenWidth = 2138, + HttpBrowserTimeDifference = "", + UserAgentBrowserValue = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + }; + + [Fact] + public async Task ChargeAsync_SendsPostWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "status": "SUCCESS", "message": "Transaction Successful", + "transactionReference": "MNFY|99|20220725110839|000256", + "paymentReference": "1234567890-abcdef", "authorizedAmount": 100 + } } + """)); + var client = CreateClient(handler); + + var result = await client.ChargeAsync(CreateChargeRequest()); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/merchant/cards/charge", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"number\":\"4111111111111111\"", handler.RequestBodies[0]); + Assert.Contains("\"collectionChannel\":\"API_NOTIFICATION\"", handler.RequestBodies[0]); + Assert.Equal("SUCCESS", result.Status); + Assert.Equal(100, result.AuthorizedAmount); + Assert.Equal("1234567890-abcdef", result.PaymentReference); + } + + [Fact] + public async Task ChargeAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.ChargeAsync(null!)); + } + + [Fact] + public async Task ChargeAsync_FailedCard_ThrowsMonnifyApiException() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.BadRequest, """ + { "requestSuccessful": false, "responseMessage": "Invalid card number", "responseCode": "99" } + """)); + var client = CreateClient(handler); + + var ex = await Assert.ThrowsAsync( + () => client.ChargeAsync(CreateChargeRequest("4111111111111110"))); + + Assert.Equal("Invalid card number", ex.ResponseMessage); + } + + [Fact] + public async Task AuthorizeOtpAsync_SendsPostWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "paymentStatus": "SUCCESSFUL", "paymentDescription": "Payment Successful", + "transactionReference": "MNFY|67|20220725114827|000285", + "paymentReference": "1568577644707", "amountPaid": 100, "currencyPaid": "NGN" + } } + """)); + var client = CreateClient(handler); + + var result = await client.AuthorizeOtpAsync(new AuthorizeCardOtpRequest + { + TransactionReference = "MNFY|67|20220725114827|000285", + TokenId = "100.00-b66bef0aa8e660863c4e1177a08fefba", + Token = "123456", + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/merchant/cards/otp/authorize", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"token\":\"123456\"", handler.RequestBodies[0]); + Assert.Equal("SUCCESSFUL", result.PaymentStatus); + Assert.Equal(100, result.AmountPaid); + Assert.Equal("NGN", result.CurrencyPaid); + } + + [Fact] + public async Task AuthorizeOtpAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.AuthorizeOtpAsync(null!)); + } + + [Fact] + public async Task Authorize3dsAsync_SendsPostWithApiKeyInBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "paymentStatus": "SUCCESSFUL", "paymentDescription": "Payment Successful", + "transactionReference": "MNFY|99|20220725125351|000271", + "paymentReference": "1568577644707", "amountPaid": 100, "currencyPaid": "NGN" + } } + """)); + var client = CreateClient(handler); + + var result = await client.Authorize3dsAsync(new Authorize3dsCardRequest + { + TransactionReference = "MNFY|99|20220725125351|000271", + ApiKey = "MK_TEST_PLACEHOLDER123", + Card = new Card + { + Number = "4000000000000002", + ExpiryMonth = "12", + ExpiryYear = "2025", + Cvv = "123", + Pin = "1234", + }, + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/sdk/cards/secure-3d/authorize", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"apiKey\":\"MK_TEST_PLACEHOLDER123\"", handler.RequestBodies[0]); + Assert.Equal("SUCCESSFUL", result.PaymentStatus); + } + + [Fact] + public async Task Authorize3dsAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.Authorize3dsAsync(null!)); + } +} From 88fd0cfc7880406e829eaf83f5f4bfb4675976ba Mon Sep 17 00:00:00 2001 From: Greenfonts Date: Tue, 30 Jun 2026 09:23:18 +0100 Subject: [PATCH 2/3] docs: correct bank-transfer USSD field name discrepancy note Re-verified InitiateBankTransferAsync against a fresh sandbox call while cross-checking a newly pasted doc sample. The existing model was already right (ussdPayment, not the ussdCode our simplified doc sample shows) - this only updates docs/COMPATIBILITY.md with the verified field list and flags an unmodeled, always-null productInformation field. --- docs/COMPATIBILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index ec3aeda..152e720 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -20,7 +20,7 @@ Status legend: | `IMonnifyCollectionsClient.GetInvoiceAsync` | `GET /api/v1/invoice/{invoiceReference}/details` | **Implemented** | | | `IMonnifyCollectionsClient.GetInvoicesAsync` | `GET /api/v1/invoice/all?page=&size=` | **Implemented** | | | `IMonnifyCollectionsClient.CancelInvoiceAsync` | `DELETE /api/v1/invoice/{invoiceReference}/cancel` | **Implemented** | | -| `IMonnifyCollectionsClient.InitiateBankTransferAsync` | `POST /api/v1/merchant/bank-transfer/init-payment` | **Implemented** | Generates a one-time dynamic account for an already-initialized transaction | +| `IMonnifyCollectionsClient.InitiateBankTransferAsync` | `POST /api/v1/merchant/bank-transfer/init-payment` | **Implemented** | Generates a one-time dynamic account for an already-initialized transaction. Our docs' simplified sample for this endpoint shows the USSD field as `ussdCode`; the real sandbox response returns `ussdPayment` instead (which is what `BankTransferPaymentDetails.UssdPayment` already maps), alongside several fields that sample omits entirely (`accountDurationSeconds`, `requestTime`, `expiresOn`, `amount`, `fee`, `totalPayable`, `collectionChannel`). Also saw an always-null `productInformation` field not currently modeled - shape unknown | | `IMonnifyCollectionsClient.SearchTransactionsAsync` | `GET /api/v1/transactions/search?page=&size=&...` | **Implemented** | Many optional filters (reference, amount range, customer, status, date range) | | `IMonnifyCollectionsClient.GetTransactionAsync` | `GET /api/v2/transactions/{transactionReference}` | **Implemented** | Amount fields are quoted strings; `decimal` via `JsonNumberHandling.AllowReadingFromString` | | `IMonnifyCollectionsClient.QueryTransactionAsync` | `GET /api/v2/merchant/transactions/query?transactionReference=&paymentReference=` | **Implemented** | Same response shape as `GetTransactionAsync`; requires at least one query parameter | From cd902ff202791a0c5b643217ffe74f9d156492c9 Mon Sep 17 00:00:00 2001 From: Greenfonts Date: Tue, 30 Jun 2026 09:37:22 +0100 Subject: [PATCH 3/3] refactor: fold card transactions into IMonnifyCollectionsClient Card charging completes a transaction started by InitializeTransactionAsync, the same way InitiateBankTransferAsync does - moved ChargeAsync, AuthorizeOtpAsync, and Authorize3dsAsync (plus their models) onto IMonnifyCollectionsClient instead of a separate IMonnifyCardsClient, under Collections/Models/Transactions. The whole Collections client now registers with automatic retry disabled, since ChargeAsync directly debits a card - more conservative than its other (non-money-moving) methods strictly need, traded for keeping every collection method on one client. Also corrected the bank-transfer doc note found while cross-referencing this: the real ussdPayment response field, not ussdCode as our simplified doc sample shows - the SDK already had this right. --- CHANGELOG.md | 9 ++-- docs/COMPATIBILITY.md | 8 +-- src/Monnify/Cards/IMonnifyCardsClient.cs | 37 -------------- src/Monnify/Cards/MonnifyCardsClient.cs | 50 ------------------- .../Collections/IMonnifyCollectionsClient.cs | 29 +++++++++++ .../Transactions}/Authorize3dsCardRequest.cs | 2 +- .../Transactions}/AuthorizeCardOtpRequest.cs | 2 +- .../Transactions}/AuthorizeCardOtpResult.cs | 2 +- .../Models/Transactions}/Card.cs | 2 +- .../Models/Transactions}/ChargeCardRequest.cs | 2 +- .../Models/Transactions}/ChargeCardResult.cs | 2 +- .../Models/Transactions}/DeviceInformation.cs | 2 +- .../Collections/MonnifyCollectionsClient.cs | 42 ++++++++++++++++ src/Monnify/Http/MonnifyApiPaths.cs | 14 +++--- src/Monnify/MonnifyClient.cs | 7 +-- src/Monnify/ServiceCollectionExtensions.cs | 17 ++++--- .../MonnifyCollectionsClientCardsTests.cs} | 10 ++-- 17 files changed, 110 insertions(+), 127 deletions(-) delete mode 100644 src/Monnify/Cards/IMonnifyCardsClient.cs delete mode 100644 src/Monnify/Cards/MonnifyCardsClient.cs rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/Authorize3dsCardRequest.cs (95%) rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/AuthorizeCardOtpRequest.cs (96%) rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/AuthorizeCardOtpResult.cs (96%) rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/Card.cs (96%) rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/ChargeCardRequest.cs (95%) rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/ChargeCardResult.cs (97%) rename src/Monnify/{Cards/Models => Collections/Models/Transactions}/DeviceInformation.cs (97%) rename tests/Monnify.Tests/{Cards/MonnifyCardsClientTests.cs => Collections/Transactions/MonnifyCollectionsClientCardsTests.cs} (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 451eb86..94a5e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ own version is independent of Monnify's API versioning. ## [Unreleased] ### Added -- `IMonnifyCardsClient` (`ChargeAsync`, `AuthorizeOtpAsync`, `Authorize3dsAsync`) for direct card - charges, exposed on `MonnifyClient` as `Cards`. Automatic retry disabled, same reasoning as - Disbursements/Bills. See docs/COMPATIBILITY.md for sandbox-verification notes. +- `IMonnifyCollectionsClient`: `ChargeAsync`, `AuthorizeOtpAsync`, `Authorize3dsAsync` for direct + card charges. The whole client now registers with automatic retry disabled, same reasoning as + Disbursements/Bills, since `ChargeAsync` directly debits a card. See docs/COMPATIBILITY.md for + sandbox-verification notes. +- Corrected `InitiateBankTransferAsync`'s doc note: the real response field is `ussdPayment`, not + `ussdCode` as our simplified doc sample shows (the SDK already had this right). ## [0.1.0] - 2026-06-29 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 152e720..5688db0 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -20,7 +20,10 @@ Status legend: | `IMonnifyCollectionsClient.GetInvoiceAsync` | `GET /api/v1/invoice/{invoiceReference}/details` | **Implemented** | | | `IMonnifyCollectionsClient.GetInvoicesAsync` | `GET /api/v1/invoice/all?page=&size=` | **Implemented** | | | `IMonnifyCollectionsClient.CancelInvoiceAsync` | `DELETE /api/v1/invoice/{invoiceReference}/cancel` | **Implemented** | | -| `IMonnifyCollectionsClient.InitiateBankTransferAsync` | `POST /api/v1/merchant/bank-transfer/init-payment` | **Implemented** | Generates a one-time dynamic account for an already-initialized transaction. Our docs' simplified sample for this endpoint shows the USSD field as `ussdCode`; the real sandbox response returns `ussdPayment` instead (which is what `BankTransferPaymentDetails.UssdPayment` already maps), alongside several fields that sample omits entirely (`accountDurationSeconds`, `requestTime`, `expiresOn`, `amount`, `fee`, `totalPayable`, `collectionChannel`). Also saw an always-null `productInformation` field not currently modeled - shape unknown | +| `IMonnifyCollectionsClient.InitiateBankTransferAsync` | `POST /api/v1/merchant/bank-transfer/init-payment` | **Implemented** | Real response field is `ussdPayment`, not `ussdCode` as our simplified doc sample shows | +| `IMonnifyCollectionsClient.ChargeAsync` | `POST /api/v1/merchant/cards/charge` | **Implemented** | Requires a `transactionReference` from `InitializeTransactionAsync` first. Automatic retry disabled - this directly debits a card | +| `IMonnifyCollectionsClient.AuthorizeOtpAsync` | `POST /api/v1/merchant/cards/otp/authorize` | **Implemented** | Not sandbox-verified - blocked on the `ChargeAsync` bin issue above | +| `IMonnifyCollectionsClient.Authorize3dsAsync` | `POST /api/v1/sdk/cards/secure-3d/authorize` | **Implemented** | Restricted to PCI DSS-certified merchants, requires separate activation | | `IMonnifyCollectionsClient.SearchTransactionsAsync` | `GET /api/v1/transactions/search?page=&size=&...` | **Implemented** | Many optional filters (reference, amount range, customer, status, date range) | | `IMonnifyCollectionsClient.GetTransactionAsync` | `GET /api/v2/transactions/{transactionReference}` | **Implemented** | Amount fields are quoted strings; `decimal` via `JsonNumberHandling.AllowReadingFromString` | | `IMonnifyCollectionsClient.QueryTransactionAsync` | `GET /api/v2/merchant/transactions/query?transactionReference=&paymentReference=` | **Implemented** | Same response shape as `GetTransactionAsync`; requires at least one query parameter | @@ -56,9 +59,6 @@ Status legend: | `IMonnifyBillsClient.ValidateCustomerAsync` | `POST /api/v1/vas/bills-payment/validate-customer` | **Implemented** | `validationReference` lives inside `vendInstruction`, not top-level as our docs prose implies. `minAmount`/`maxAmount` can appear top-level for amount-constrained products | | `IMonnifyBillsClient.VendAsync` | `POST /api/v1/vas/bills-payment/vend` | **Implemented** | Automatic retry disabled, same reasoning as Disbursements | | `IMonnifyBillsClient.RequeryAsync` | `GET /api/v1/vas/bills-payment/requery?reference=` | **Implemented** | Same response shape as `VendAsync` | -| `IMonnifyCardsClient.ChargeAsync` | `POST /api/v1/merchant/cards/charge` | **Implemented** | Requires a `transactionReference` from `InitializeTransactionAsync` first. Automatic retry disabled - this directly debits a card | -| `IMonnifyCardsClient.AuthorizeOtpAsync` | `POST /api/v1/merchant/cards/otp/authorize` | **Implemented** | Not sandbox-verified - blocked on the `ChargeAsync` bin issue above, since this needs a real `tokenId` from a successful OTP-required charge | -| `IMonnifyCardsClient.Authorize3dsAsync` | `POST /api/v1/sdk/cards/secure-3d/authorize` | **Implemented** | Restricted to PCI DSS-certified merchants, requires separate activation. Calling it without a prior successful charge correctly returned `"No Card Payment found for this transaction."` rather than an auth/activation error, so the endpoint itself is reachable on this account; the OTP-required response shape is otherwise unverified for the same reason as `AuthorizeOtpAsync` | Update this table whenever a method moves from Planned to Implemented, or its endpoint changes. diff --git a/src/Monnify/Cards/IMonnifyCardsClient.cs b/src/Monnify/Cards/IMonnifyCardsClient.cs deleted file mode 100644 index f6e4003..0000000 --- a/src/Monnify/Cards/IMonnifyCardsClient.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Monnify.Http; - -namespace Monnify.Cards; - -/// -/// Direct card charges (raw PAN/CVV/PIN) against an already-initialized transaction, as an -/// alternative to the hosted checkout flow in . -/// -/// -/// This client is registered with automatic HTTP retry disabled, for the same reason as -/// and : -/// directly attempts to debit a card, so an ambiguous failure (timeout, -/// network error, 5xx) must not be blindly resent with the same details - that risks a double -/// charge. Query the transaction's status via -/// with the same -/// transactionReference instead. -/// -public interface IMonnifyCardsClient -{ - /// - /// Charges a card against a transaction reference obtained from - /// . Depending on - /// the card, the result is either immediately final, or requires a follow-up call to - /// (OTP) or (3DS) to complete. - /// - Task ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default); - - /// Completes a charge that required an OTP, using the token ID from 's response. - Task AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default); - - /// - /// Completes a charge on a card that uses 3D Secure authentication. This is not active by - /// default - it's restricted to PCI DSS-certified merchants and requires it to be enabled for - /// your account first. - /// - Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default); -} diff --git a/src/Monnify/Cards/MonnifyCardsClient.cs b/src/Monnify/Cards/MonnifyCardsClient.cs deleted file mode 100644 index d72bc99..0000000 --- a/src/Monnify/Cards/MonnifyCardsClient.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Monnify.Http; - -namespace Monnify.Cards; - -internal sealed class MonnifyCardsClient : MonnifyHttpClientBase, IMonnifyCardsClient -{ - public MonnifyCardsClient(HttpClient httpClient) : base(httpClient) { } - - public Task ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default) - { - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } - - var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Cards.Charge) - { - Content = CreateJsonContent(request), - }; - return SendAsync(httpRequest, cancellationToken); - } - - public Task AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default) - { - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } - - var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Cards.AuthorizeOtp) - { - Content = CreateJsonContent(request), - }; - return SendAsync(httpRequest, cancellationToken); - } - - public Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default) - { - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } - - var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Cards.Authorize3ds) - { - Content = CreateJsonContent(request), - }; - return SendAsync(httpRequest, cancellationToken); - } -} diff --git a/src/Monnify/Collections/IMonnifyCollectionsClient.cs b/src/Monnify/Collections/IMonnifyCollectionsClient.cs index c09ecdb..3d9f31a 100644 --- a/src/Monnify/Collections/IMonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/IMonnifyCollectionsClient.cs @@ -2,6 +2,17 @@ namespace Monnify.Collections; +/// +/// Registered with automatic HTTP retry disabled: directly attempts to +/// debit a card, so an ambiguous failure (timeout, network error, 5xx) must not be blindly resent +/// with the same details - that risks a double charge, same reasoning as +/// and . +/// Query with the same transactionReference instead. The +/// other methods on this client don't move money directly (they set up a payment instrument the +/// customer still has to complete), so this is a more conservative retry stance than they +/// strictly need, traded for keeping every collection method - including card charges - on one +/// client. +/// public interface IMonnifyCollectionsClient { /// Starts a checkout, returning a hosted page URL to redirect the customer to. @@ -37,6 +48,24 @@ Task> GetReservedAccountTransacti Task InitiateBankTransferAsync( InitiateBankTransferRequest request, CancellationToken cancellationToken = default); + /// + /// Charges a card (raw PAN/CVV/PIN) against a transaction reference from + /// , as an alternative to the hosted checkout flow. + /// Depending on the card, the result is either immediately final, or requires a follow-up call + /// to (OTP) or (3DS) to complete. + /// + Task ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default); + + /// Completes a charge that required an OTP, using the token ID from 's response. + Task AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default); + + /// + /// Completes a charge on a card that uses 3D Secure authentication. This is not active by + /// default - it's restricted to PCI DSS-certified merchants and requires it to be enabled for + /// your account first. + /// + Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default); + /// Searches transactions on the integration, optionally filtered by reference, amount range, customer, status, or date range. Task> SearchTransactionsAsync( SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default); diff --git a/src/Monnify/Cards/Models/Authorize3dsCardRequest.cs b/src/Monnify/Collections/Models/Transactions/Authorize3dsCardRequest.cs similarity index 95% rename from src/Monnify/Cards/Models/Authorize3dsCardRequest.cs rename to src/Monnify/Collections/Models/Transactions/Authorize3dsCardRequest.cs index bdffc51..3e97587 100644 --- a/src/Monnify/Cards/Models/Authorize3dsCardRequest.cs +++ b/src/Monnify/Collections/Models/Transactions/Authorize3dsCardRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; public sealed class Authorize3dsCardRequest { diff --git a/src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpRequest.cs similarity index 96% rename from src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs rename to src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpRequest.cs index 8e97b27..d1d15b9 100644 --- a/src/Monnify/Cards/Models/AuthorizeCardOtpRequest.cs +++ b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; public sealed class AuthorizeCardOtpRequest { diff --git a/src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpResult.cs similarity index 96% rename from src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs rename to src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpResult.cs index 285b3b4..51136ee 100644 --- a/src/Monnify/Cards/Models/AuthorizeCardOtpResult.cs +++ b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpResult.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; /// Returned by both AuthorizeOtpAsync and Authorize3dsAsync - they share the same shape. public sealed class AuthorizeCardOtpResult diff --git a/src/Monnify/Cards/Models/Card.cs b/src/Monnify/Collections/Models/Transactions/Card.cs similarity index 96% rename from src/Monnify/Cards/Models/Card.cs rename to src/Monnify/Collections/Models/Transactions/Card.cs index 2b42416..78ea065 100644 --- a/src/Monnify/Cards/Models/Card.cs +++ b/src/Monnify/Collections/Models/Transactions/Card.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; /// /// Raw card details for a direct charge. All fields are modeled as strings (rather than the diff --git a/src/Monnify/Cards/Models/ChargeCardRequest.cs b/src/Monnify/Collections/Models/Transactions/ChargeCardRequest.cs similarity index 95% rename from src/Monnify/Cards/Models/ChargeCardRequest.cs rename to src/Monnify/Collections/Models/Transactions/ChargeCardRequest.cs index 29ab0f7..804be54 100644 --- a/src/Monnify/Cards/Models/ChargeCardRequest.cs +++ b/src/Monnify/Collections/Models/Transactions/ChargeCardRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; public sealed class ChargeCardRequest { diff --git a/src/Monnify/Cards/Models/ChargeCardResult.cs b/src/Monnify/Collections/Models/Transactions/ChargeCardResult.cs similarity index 97% rename from src/Monnify/Cards/Models/ChargeCardResult.cs rename to src/Monnify/Collections/Models/Transactions/ChargeCardResult.cs index c1ec619..ef07ab6 100644 --- a/src/Monnify/Cards/Models/ChargeCardResult.cs +++ b/src/Monnify/Collections/Models/Transactions/ChargeCardResult.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; public sealed class ChargeCardResult { diff --git a/src/Monnify/Cards/Models/DeviceInformation.cs b/src/Monnify/Collections/Models/Transactions/DeviceInformation.cs similarity index 97% rename from src/Monnify/Cards/Models/DeviceInformation.cs rename to src/Monnify/Collections/Models/Transactions/DeviceInformation.cs index 70c4607..8ec8317 100644 --- a/src/Monnify/Cards/Models/DeviceInformation.cs +++ b/src/Monnify/Collections/Models/Transactions/DeviceInformation.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Monnify.Cards; +namespace Monnify.Collections; /// /// Browser/device fingerprint sent alongside a card charge, used for fraud screening and 3DS. diff --git a/src/Monnify/Collections/MonnifyCollectionsClient.cs b/src/Monnify/Collections/MonnifyCollectionsClient.cs index bd2da8a..0e9b4c5 100644 --- a/src/Monnify/Collections/MonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/MonnifyCollectionsClient.cs @@ -109,6 +109,48 @@ public Task InitiateBankTransferAsync( return SendAsync(httpRequest, cancellationToken); } + public Task ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Cards.Charge) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Cards.AuthorizeOtp) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Cards.Authorize3ds) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + public Task> SearchTransactionsAsync( SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default) { diff --git a/src/Monnify/Http/MonnifyApiPaths.cs b/src/Monnify/Http/MonnifyApiPaths.cs index ac9ef54..afa21cf 100644 --- a/src/Monnify/Http/MonnifyApiPaths.cs +++ b/src/Monnify/Http/MonnifyApiPaths.cs @@ -50,6 +50,13 @@ internal static class Invoices public const string Create = "/api/v1/invoice/create"; public const string Base = "/api/v1/invoice"; } + + internal static class Cards + { + public const string Charge = "/api/v1/merchant/cards/charge"; + public const string AuthorizeOtp = "/api/v1/merchant/cards/otp/authorize"; + public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize"; + } } internal static class Disbursements @@ -85,11 +92,4 @@ internal static class Bills public const string Requery = "/api/v1/vas/bills-payment/requery"; public const string ValidateCustomer = "/api/v1/vas/bills-payment/validate-customer"; } - - internal static class Cards - { - public const string Charge = "/api/v1/merchant/cards/charge"; - public const string AuthorizeOtp = "/api/v1/merchant/cards/otp/authorize"; - public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize"; - } } diff --git a/src/Monnify/MonnifyClient.cs b/src/Monnify/MonnifyClient.cs index 5020be2..21ad72b 100644 --- a/src/Monnify/MonnifyClient.cs +++ b/src/Monnify/MonnifyClient.cs @@ -1,6 +1,5 @@ using Monnify.Banks; using Monnify.Bills; -using Monnify.Cards; using Monnify.Collections; using Monnify.Disbursements; using Monnify.Verification; @@ -19,15 +18,13 @@ public MonnifyClient( IMonnifyVerificationClient verification, IMonnifyCollectionsClient collections, IMonnifyDisbursementsClient disbursements, - IMonnifyBillsClient bills, - IMonnifyCardsClient cards) + IMonnifyBillsClient bills) { Banks = banks; Verification = verification; Collections = collections; Disbursements = disbursements; Bills = bills; - Cards = cards; } public IMonnifyBanksClient Banks { get; } @@ -39,6 +36,4 @@ public MonnifyClient( public IMonnifyDisbursementsClient Disbursements { get; } public IMonnifyBillsClient Bills { get; } - - public IMonnifyCardsClient Cards { get; } } diff --git a/src/Monnify/ServiceCollectionExtensions.cs b/src/Monnify/ServiceCollectionExtensions.cs index a9c53c1..bc0e24b 100644 --- a/src/Monnify/ServiceCollectionExtensions.cs +++ b/src/Monnify/ServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using Monnify.Authentication; using Monnify.Banks; using Monnify.Bills; -using Monnify.Cards; using Monnify.Collections; using Monnify.Disbursements; using Monnify.Http; @@ -49,7 +48,15 @@ public static IServiceCollection AddMonnify(this IServiceCollection services, Ac services.AddHttpClient(ConfigureBaseAddress).AddMonnifyDefaults(); services.AddHttpClient(ConfigureBaseAddress).AddMonnifyDefaults(); - services.AddHttpClient(ConfigureBaseAddress).AddMonnifyDefaults(); + + // allowAutomaticRetry: false - ChargeAsync directly debits a card, same reasoning as + // Disbursements/Bills below: an ambiguous failure must be resolved by querying the + // transaction's status, not retried with the same card details. This is more conservative + // than the rest of this client strictly needs (its other methods set up a payment + // instrument rather than moving money directly), traded for keeping every collection + // method, including card charges, on one client. + services.AddHttpClient(ConfigureBaseAddress) + .AddMonnifyDefaults(allowAutomaticRetry: false); // allowAutomaticRetry: false - an ambiguous failure on a transfer-initiating call must be // resolved by querying status with the same reference, not by blindly resending the same @@ -62,12 +69,6 @@ public static IServiceCollection AddMonnify(this IServiceCollection services, Ac services.AddHttpClient(ConfigureBaseAddress) .AddMonnifyDefaults(allowAutomaticRetry: false); - // allowAutomaticRetry: false - ChargeAsync directly debits a card, same reasoning as - // Disbursements/Bills above: an ambiguous failure must be resolved by querying the - // transaction's status, not retried with the same card details. - services.AddHttpClient(ConfigureBaseAddress) - .AddMonnifyDefaults(allowAutomaticRetry: false); - services.AddTransient(); return services; diff --git a/tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs b/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs similarity index 95% rename from tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs rename to tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs index 5e617c3..aff5034 100644 --- a/tests/Monnify.Tests/Cards/MonnifyCardsClientTests.cs +++ b/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs @@ -1,15 +1,15 @@ using System.Net; -using Monnify.Cards; +using Monnify.Collections; using Monnify.Exceptions; using Monnify.Tests.TestUtilities; -namespace Monnify.Tests.Cards; +namespace Monnify.Tests.Collections.Transactions; // Payloads below are from our own documented samples for these endpoints; see -// docs/COMPATIBILITY.md for sandbox-verification status once that's done. -public class MonnifyCardsClientTests +// docs/COMPATIBILITY.md for sandbox-verification status. +public class MonnifyCollectionsClientCardsTests { - private static MonnifyCardsClient CreateClient(FakeHttpMessageHandler handler) => + private static MonnifyCollectionsClient CreateClient(FakeHttpMessageHandler handler) => new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); private static ChargeCardRequest CreateChargeRequest(string cardNumber = "4111111111111111") => new()