Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
29 changes: 29 additions & 0 deletions src/Monnify/Collections/IMonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

namespace Monnify.Collections;

/// <remarks>
/// Registered with automatic HTTP retry disabled: <see cref="ChargeAsync"/> 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
/// <see cref="Disbursements.IMonnifyDisbursementsClient"/> and <see cref="Bills.IMonnifyBillsClient"/>.
/// Query <see cref="GetTransactionAsync"/> with the same <c>transactionReference</c> 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.
/// </remarks>
public interface IMonnifyCollectionsClient
{
/// <summary>Starts a checkout, returning a hosted page URL to redirect the customer to.</summary>
Expand Down Expand Up @@ -37,6 +48,24 @@ Task<MonnifyPagedResult<ReservedAccountTransaction>> GetReservedAccountTransacti
Task<BankTransferPaymentDetails> InitiateBankTransferAsync(
InitiateBankTransferRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Charges a card (raw PAN/CVV/PIN) against a transaction reference from
/// <see cref="InitializeTransactionAsync"/>, 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 <see cref="AuthorizeOtpAsync"/> (OTP) or <see cref="Authorize3dsAsync"/> (3DS) to complete.
/// </summary>
Task<ChargeCardResult> ChargeAsync(ChargeCardRequest request, CancellationToken cancellationToken = default);

/// <summary>Completes a charge that required an OTP, using the token ID from <see cref="ChargeAsync"/>'s response.</summary>
Task<AuthorizeCardOtpResult> AuthorizeOtpAsync(AuthorizeCardOtpRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
Task<AuthorizeCardOtpResult> Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default);

/// <summary>Searches transactions on the integration, optionally filtered by reference, amount range, customer, status, or date range.</summary>
Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>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.</summary>
[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();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

public sealed class AuthorizeCardOtpRequest
{
/// <summary>The transaction reference from the <c>ChargeAsync</c> call.</summary>
[JsonPropertyName("transactionReference")]
public string TransactionReference { get; set; } = string.Empty;

[JsonPropertyName("collectionChannel")]
public string CollectionChannel { get; set; } = "API_NOTIFICATION";

/// <summary>The token ID returned by the <c>ChargeAsync</c> response for a card that requires OTP.</summary>
[JsonPropertyName("tokenId")]
public string TokenId { get; set; } = string.Empty;

/// <summary>The OTP sent to the cardholder's device.</summary>
[JsonPropertyName("token")]
public string Token { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

/// <summary>Returned by both <c>AuthorizeOtpAsync</c> and <c>Authorize3dsAsync</c> - they share the same shape.</summary>
public sealed class AuthorizeCardOtpResult
{
/// <summary>E.g. <c>SUCCESSFUL</c>.</summary>
[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;
}
29 changes: 29 additions & 0 deletions src/Monnify/Collections/Models/Transactions/Card.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

/// <summary>
/// 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 <see cref="ExpiryMonth"/>, <see cref="Cvv"/>, or
/// <see cref="Pin"/>.
/// </summary>
public sealed class Card
{
[JsonPropertyName("number")]
public string Number { get; set; } = string.Empty;

/// <summary>Two digits, e.g. <c>"01"</c>-<c>"12"</c>.</summary>
[JsonPropertyName("expiryMonth")]
public string ExpiryMonth { get; set; } = string.Empty;

/// <summary>Four digits, e.g. <c>"2025"</c>.</summary>
[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;
}
19 changes: 19 additions & 0 deletions src/Monnify/Collections/Models/Transactions/ChargeCardRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

public sealed class ChargeCardRequest
{
/// <summary>A transaction reference from a prior <c>InitializeTransactionAsync</c> call.</summary>
[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();
}
32 changes: 32 additions & 0 deletions src/Monnify/Collections/Models/Transactions/ChargeCardResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

public sealed class ChargeCardResult
{
/// <summary>E.g. <c>SUCCESS</c> for a card that doesn't require OTP/3DS.</summary>
[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; }

/// <summary>
/// Present when the card requires OTP - pass it to <c>AuthorizeOtpAsync</c>. 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.
/// </summary>
[JsonPropertyName("tokenId")]
public string? TokenId { get; set; }
}
35 changes: 35 additions & 0 deletions src/Monnify/Collections/Models/Transactions/DeviceInformation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

/// <summary>
/// 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.
/// </summary>
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; }

/// <summary>The browser's UTC offset in minutes, as a string. Our own documented sample leaves this empty.</summary>
[JsonPropertyName("httpBrowserTimeDifference")]
public string HttpBrowserTimeDifference { get; set; } = string.Empty;

[JsonPropertyName("userAgentBrowserValue")]
public string UserAgentBrowserValue { get; set; } = string.Empty;
}
42 changes: 42 additions & 0 deletions src/Monnify/Collections/MonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,48 @@ public Task<BankTransferPaymentDetails> InitiateBankTransferAsync(
return SendAsync<BankTransferPaymentDetails>(httpRequest, cancellationToken);
}

public Task<ChargeCardResult> 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<ChargeCardResult>(httpRequest, cancellationToken);
}

public Task<AuthorizeCardOtpResult> 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<AuthorizeCardOtpResult>(httpRequest, cancellationToken);
}

public Task<AuthorizeCardOtpResult> 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<AuthorizeCardOtpResult>(httpRequest, cancellationToken);
}

public Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default)
{
Expand Down
7 changes: 7 additions & 0 deletions src/Monnify/Http/MonnifyApiPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/Monnify/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ public static IServiceCollection AddMonnify(this IServiceCollection services, Ac

services.AddHttpClient<IMonnifyBanksClient, MonnifyBanksClient>(ConfigureBaseAddress).AddMonnifyDefaults();
services.AddHttpClient<IMonnifyVerificationClient, MonnifyVerificationClient>(ConfigureBaseAddress).AddMonnifyDefaults();
services.AddHttpClient<IMonnifyCollectionsClient, MonnifyCollectionsClient>(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<IMonnifyCollectionsClient, MonnifyCollectionsClient>(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
Expand Down
Loading
Loading