diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3fb3eb..94a5e74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,14 @@ own version is independent of Monnify's API versioning.
## [Unreleased]
+### Added
+- `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
### Fixed
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 3547721..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 |
+| `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 |
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/Collections/Models/Transactions/Authorize3dsCardRequest.cs b/src/Monnify/Collections/Models/Transactions/Authorize3dsCardRequest.cs
new file mode 100644
index 0000000..3e97587
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/Authorize3dsCardRequest.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+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/Collections/Models/Transactions/AuthorizeCardOtpRequest.cs b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpRequest.cs
new file mode 100644
index 0000000..d1d15b9
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpRequest.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+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/Collections/Models/Transactions/AuthorizeCardOtpResult.cs b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpResult.cs
new file mode 100644
index 0000000..51136ee
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/AuthorizeCardOtpResult.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+/// 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/Collections/Models/Transactions/Card.cs b/src/Monnify/Collections/Models/Transactions/Card.cs
new file mode 100644
index 0000000..78ea065
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/Card.cs
@@ -0,0 +1,29 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+///
+/// 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/Collections/Models/Transactions/ChargeCardRequest.cs b/src/Monnify/Collections/Models/Transactions/ChargeCardRequest.cs
new file mode 100644
index 0000000..804be54
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/ChargeCardRequest.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+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/Collections/Models/Transactions/ChargeCardResult.cs b/src/Monnify/Collections/Models/Transactions/ChargeCardResult.cs
new file mode 100644
index 0000000..ef07ab6
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/ChargeCardResult.cs
@@ -0,0 +1,32 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+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/Collections/Models/Transactions/DeviceInformation.cs b/src/Monnify/Collections/Models/Transactions/DeviceInformation.cs
new file mode 100644
index 0000000..8ec8317
--- /dev/null
+++ b/src/Monnify/Collections/Models/Transactions/DeviceInformation.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+///
+/// 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/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 d4e99d1..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
diff --git a/src/Monnify/ServiceCollectionExtensions.cs b/src/Monnify/ServiceCollectionExtensions.cs
index 221f983..bc0e24b 100644
--- a/src/Monnify/ServiceCollectionExtensions.cs
+++ b/src/Monnify/ServiceCollectionExtensions.cs
@@ -48,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
diff --git a/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs b/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs
new file mode 100644
index 0000000..aff5034
--- /dev/null
+++ b/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs
@@ -0,0 +1,162 @@
+using System.Net;
+using Monnify.Collections;
+using Monnify.Exceptions;
+using Monnify.Tests.TestUtilities;
+
+namespace Monnify.Tests.Collections.Transactions;
+
+// Payloads below are from our own documented samples for these endpoints; see
+// docs/COMPATIBILITY.md for sandbox-verification status.
+public class MonnifyCollectionsClientCardsTests
+{
+ 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()
+ {
+ 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!));
+ }
+}