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!)); + } +}