diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6a0c51..626b412 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,12 +20,13 @@ 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).
+- `IMonnifyCollectionsClient`: direct debit mandates - `CreateMandateAsync`, `GetMandatesAsync`,
+ `DebitMandateAsync`, `GetMandateDebitStatusAsync`, `CancelMandateAsync`, `ListMandatesAsync`.
+ Sandbox testing surfaced several real discrepancies with our own docs (wrong field name for the
+ merchant reference, undocumented fields, an extra `mandateStatus` value, a paging shape missing
+ fields our sample shows) - see docs/COMPATIBILITY.md for each. Also added
+ `LenientStringJsonConverter` for `GetMandateDebitStatusAsync`'s `responseMessage`, which our docs
+ sample shows as an empty object instead of a string there.
## [0.1.0] - 2026-06-29
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 5688db0..f3c2ac0 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -28,7 +28,12 @@ Status legend:
| `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 |
| *(Collections — sub-accounts/splitting)* | TBD | Planned | |
-| *(Collections — direct debit/mandates)* | TBD | Planned | |
+| `IMonnifyCollectionsClient.CreateMandateAsync` | `POST /api/v1/direct-debit/mandate/create` | **Implemented** | Sandbox returned `mandateStatus: "PENDING"` and no `redirectUrl`, not the `"INITIATED"` + echoed `redirectUrl` our docs sample shows |
+| `IMonnifyCollectionsClient.GetMandatesAsync` | `GET /api/v1/direct-debit/mandate/?mandateReferences=` | **Implemented** | The reference field in the response is `mandateReference`, not `externalMandateReference` as our docs sample shows. Two undocumented fields found: `responseMessage` and `schemeCode`. `mandateStatus` can be `PENDING_AUTHORIZATION`, not in our docs' status list |
+| `IMonnifyCollectionsClient.DebitMandateAsync` | `POST /api/v1/direct-debit/mandate/debit` | **Implemented** | Not sandbox-verified end-to-end - needs an `ACTIVE` mandate. Automatic retry disabled - this directly debits a mandate |
+| `IMonnifyCollectionsClient.GetMandateDebitStatusAsync` | `GET /api/v1/direct-debit/mandate/debit-status?paymentReference=` | **Implemented** | `responseMessage` can be `{}` instead of a string - handled via `LenientStringJsonConverter` |
+| `IMonnifyCollectionsClient.CancelMandateAsync` | `PATCH /api/v1/direct-debit/mandate/cancel-mandate/{mandateCode}` | **Implemented** | Sandbox-verified against a real mandate created in this session |
+| `IMonnifyCollectionsClient.ListMandatesAsync` | `GET /api/v1/direct-debit/mandates?startDate=&endDate=&...` | **Implemented** | Own paging shape; `first`/`numberOfElements`/`empty` are nullable since the sandbox sometimes omits them |
| *(Collections — card tokenization)* | TBD | Planned | |
| *(Collections — Payment Links)* | N/A | Out of scope | Dashboard-only feature, no API |
| `IMonnifyDisbursementsClient.InitiateSingleTransferAsync` | `POST /api/v2/disbursements/single` | **Implemented** | Requires Transfer feature activation (sales@monnify.com); automatic retry disabled |
diff --git a/src/Monnify/Collections/IMonnifyCollectionsClient.cs b/src/Monnify/Collections/IMonnifyCollectionsClient.cs
index 3d9f31a..14a0239 100644
--- a/src/Monnify/Collections/IMonnifyCollectionsClient.cs
+++ b/src/Monnify/Collections/IMonnifyCollectionsClient.cs
@@ -4,14 +4,15 @@ 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
+/// debit a card and directly debits a mandate, so an ambiguous
+/// failure (timeout, network error, 5xx) on either 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.
+/// Query or with the
+/// same reference instead. Most 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 and mandate debits - on one client.
///
public interface IMonnifyCollectionsClient
{
@@ -76,4 +77,40 @@ Task> SearchTransactionsAsync(
/// Gets the status of a transaction by either its Monnify transaction reference or the merchant's payment reference.
Task QueryTransactionAsync(
string? transactionReference = null, string? paymentReference = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Initiates a direct debit mandate - a recurring authorization to debit a customer's bank
+ /// account. The mandate isn't active until the customer completes authorization (a token
+ /// transfer) via or the
+ /// returned by .
+ ///
+ Task CreateMandateAsync(CreateMandateRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Looks up one or more mandates by reference. Despite the singular use case, the endpoint's
+ /// query parameter and response are both plural - pass a single reference or several
+ /// comma-separated, and always get a list back.
+ ///
+ Task> GetMandatesAsync(string mandateReferences, CancellationToken cancellationToken = default);
+
+ ///
+ /// Debits a single payment from an active mandate. See the remarks on
+ /// for the safe retry pattern after an ambiguous
+ /// failure.
+ ///
+ Task DebitMandateAsync(DebitMandateRequest request, CancellationToken cancellationToken = default);
+
+ /// Checks the status of a previous call by its payment reference.
+ Task GetMandateDebitStatusAsync(string paymentReference, CancellationToken cancellationToken = default);
+
+ /// Requests cancellation of a mandate.
+ Task CancelMandateAsync(string mandateCode, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists mandates created or initiated by the merchant. 's
+ /// / are
+ /// required and the range between them must not exceed 90 days.
+ ///
+ Task ListMandatesAsync(
+ ListMandatesFilter filter, int page = 0, int limit = 20, CancellationToken cancellationToken = default);
}
diff --git a/src/Monnify/Collections/Models/Mandates/CreateMandateRequest.cs b/src/Monnify/Collections/Models/Mandates/CreateMandateRequest.cs
new file mode 100644
index 0000000..09761c6
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/CreateMandateRequest.cs
@@ -0,0 +1,64 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+public sealed class CreateMandateRequest
+{
+ [JsonPropertyName("contractCode")]
+ public string ContractCode { get; set; } = string.Empty;
+
+ /// Your own reference to identify this mandate - returned back as .
+ [JsonPropertyName("mandateReference")]
+ public string MandateReference { get; set; } = string.Empty;
+
+ /// Total lifetime amount debitable on the mandate.
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ [JsonPropertyName("mandateAmount")]
+ public decimal MandateAmount { get; set; }
+
+ /// Whether the mandate renews automatically once it reaches .
+ [JsonPropertyName("autoRenew")]
+ public bool AutoRenew { get; set; }
+
+ /// Whether the customer is allowed to cancel this mandate themselves.
+ [JsonPropertyName("customerCancellation")]
+ public bool CustomerCancellation { get; set; }
+
+ [JsonPropertyName("customerName")]
+ public string CustomerName { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerPhoneNumber")]
+ public string CustomerPhoneNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerEmailAddress")]
+ public string CustomerEmailAddress { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerAddress")]
+ public string CustomerAddress { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerAccountNumber")]
+ public string CustomerAccountNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerAccountBankCode")]
+ public string CustomerAccountBankCode { get; set; } = string.Empty;
+
+ [JsonPropertyName("mandateDescription")]
+ public string MandateDescription { get; set; } = string.Empty;
+
+ /// Format: YYYY-MM-DDTHH:MM:SS.
+ [JsonPropertyName("mandateStartDate")]
+ public string MandateStartDate { get; set; } = string.Empty;
+
+ /// Format: YYYY-MM-DDTHH:MM:SS.
+ [JsonPropertyName("mandateEndDate")]
+ public string MandateEndDate { get; set; } = string.Empty;
+
+ /// Where to redirect the customer to after they complete authorization.
+ [JsonPropertyName("redirectUrl")]
+ public string? RedirectUrl { get; set; }
+
+ /// The amount to be debited per periodic debit, if different from the lifetime .
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ [JsonPropertyName("debitAmount")]
+ public decimal? DebitAmount { get; set; }
+}
diff --git a/src/Monnify/Collections/Models/Mandates/DebitMandateRequest.cs b/src/Monnify/Collections/Models/Mandates/DebitMandateRequest.cs
new file mode 100644
index 0000000..1ba72f7
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/DebitMandateRequest.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+public sealed class DebitMandateRequest
+{
+ /// Your own reference to identify this single debit.
+ [JsonPropertyName("paymentReference")]
+ public string PaymentReference { get; set; } = string.Empty;
+
+ [JsonPropertyName("mandateCode")]
+ public string MandateCode { get; set; } = string.Empty;
+
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ [JsonPropertyName("debitAmount")]
+ public decimal DebitAmount { get; set; }
+
+ [JsonPropertyName("narration")]
+ public string Narration { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerEmail")]
+ public string CustomerEmail { get; set; } = string.Empty;
+
+ /// How to split this payment among sub-accounts, if at all.
+ [JsonPropertyName("incomeSplitConfig")]
+ public IReadOnlyList? IncomeSplitConfig { get; set; }
+}
diff --git a/src/Monnify/Collections/Models/Mandates/ListMandatesFilter.cs b/src/Monnify/Collections/Models/Mandates/ListMandatesFilter.cs
new file mode 100644
index 0000000..bd8000e
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/ListMandatesFilter.cs
@@ -0,0 +1,19 @@
+namespace Monnify.Collections;
+
+/// Optional filters for . / are required by the endpoint.
+public sealed class ListMandatesFilter
+{
+ /// Required. Format: YYYY-MM-DDTHH:MM:SS. The range with must not exceed 90 days.
+ public string StartDate { get; set; } = string.Empty;
+
+ /// Required. Format: YYYY-MM-DDTHH:MM:SS. The range with must not exceed 90 days.
+ public string EndDate { get; set; } = string.Empty;
+
+ public string? CustomerEmail { get; set; }
+
+ /// Direct debit scheme code, e.g. ADD.
+ public string? SchemeCode { get; set; }
+
+ /// One of PENDING, ACTIVE, FAILED, CANCELLED, EXPIRED.
+ public string? MandateStatus { get; set; }
+}
diff --git a/src/Monnify/Collections/Models/Mandates/Mandate.cs b/src/Monnify/Collections/Models/Mandates/Mandate.cs
new file mode 100644
index 0000000..e70e512
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/Mandate.cs
@@ -0,0 +1,92 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+public sealed class Mandate
+{
+ [JsonPropertyName("mandateCode")]
+ public string MandateCode { get; set; } = string.Empty;
+
+ ///
+ /// The merchant-generated reference supplied as mandateReference at creation time. Our
+ /// docs' sample for this endpoint shows the field as externalMandateReference; the real
+ /// sandbox returns mandateReference instead.
+ ///
+ [JsonPropertyName("mandateReference")]
+ public string MandateReference { get; set; } = string.Empty;
+
+ /// Format: YYYY-MM-DDTHH:MM:SS (with milliseconds and a timezone offset on responses).
+ [JsonPropertyName("startDate")]
+ public string StartDate { get; set; } = string.Empty;
+
+ /// Format: YYYY-MM-DDTHH:MM:SS (with milliseconds and a timezone offset on responses).
+ [JsonPropertyName("endDate")]
+ public string EndDate { get; set; } = string.Empty;
+
+ ///
+ /// E.g. ACTIVE, FAILED, CANCELLED, EXPIRED. Sandbox-observed values
+ /// include PENDING_AUTHORIZATION as well, which isn't in our docs' status list
+ /// (PENDING, ACTIVE, FAILED, CANCELLED, EXPIRED) - treat that
+ /// list as non-exhaustive.
+ ///
+ [JsonPropertyName("mandateStatus")]
+ public string MandateStatus { get; set; } = string.Empty;
+
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ [JsonPropertyName("mandateAmount")]
+ public decimal MandateAmount { get; set; }
+
+ [JsonPropertyName("contractCode")]
+ public string ContractCode { get; set; } = string.Empty;
+
+ [JsonPropertyName("autoRenew")]
+ public bool AutoRenew { get; set; }
+
+ [JsonPropertyName("customerPhoneNumber")]
+ public string CustomerPhoneNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerEmailAddress")]
+ public string CustomerEmailAddress { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerAddress")]
+ public string CustomerAddress { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerName")]
+ public string CustomerName { get; set; } = string.Empty;
+
+ /// The account name Monnify resolved for , distinct from .
+ [JsonPropertyName("customerAccountName")]
+ public string? CustomerAccountName { get; set; }
+
+ [JsonPropertyName("customerAccountNumber")]
+ public string CustomerAccountNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("customerAccountBankCode")]
+ public string CustomerAccountBankCode { get; set; } = string.Empty;
+
+ [JsonPropertyName("mandateDescription")]
+ public string MandateDescription { get; set; } = string.Empty;
+
+ /// The amount to be debited per periodic debit, if different from (the lifetime total).
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ [JsonPropertyName("debitAmount")]
+ public decimal? DebitAmount { get; set; }
+
+ /// Human-readable instructions for the customer to authorize the mandate (a token transfer).
+ [JsonPropertyName("authorizationMessage")]
+ public string? AuthorizationMessage { get; set; }
+
+ /// A hosted page where the customer can complete authorization.
+ [JsonPropertyName("authorizationLink")]
+ public string? AuthorizationLink { get; set; }
+
+ ///
+ /// A human-readable status message (e.g. "Active Mandate").
+ ///
+ [JsonPropertyName("responseMessage")]
+ public string? ResponseMessage { get; set; }
+
+ /// The direct debit scheme, e.g. NDD.
+ [JsonPropertyName("schemeCode")]
+ public string? SchemeCode { get; set; }
+}
diff --git a/src/Monnify/Collections/Models/Mandates/MandateActionResult.cs b/src/Monnify/Collections/Models/Mandates/MandateActionResult.cs
new file mode 100644
index 0000000..086948f
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/MandateActionResult.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+/// Returned by both CreateMandateAsync and CancelMandateAsync - they share the same shape, minus on cancel.
+public sealed class MandateActionResult
+{
+ [JsonPropertyName("responseMessage")]
+ public string ResponseMessage { get; set; } = string.Empty;
+
+ [JsonPropertyName("mandateReference")]
+ public string MandateReference { get; set; } = string.Empty;
+
+ [JsonPropertyName("mandateCode")]
+ public string MandateCode { get; set; } = string.Empty;
+
+ /// E.g. INITIATED after creation, or the mandate's prior status on cancellation.
+ [JsonPropertyName("mandateStatus")]
+ public string MandateStatus { get; set; } = string.Empty;
+
+ /// Only populated by CreateMandateAsync.
+ [JsonPropertyName("redirectUrl")]
+ public string? RedirectUrl { get; set; }
+}
diff --git a/src/Monnify/Collections/Models/Mandates/MandateDebitResult.cs b/src/Monnify/Collections/Models/Mandates/MandateDebitResult.cs
new file mode 100644
index 0000000..792e783
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/MandateDebitResult.cs
@@ -0,0 +1,37 @@
+using System.Text.Json.Serialization;
+using Monnify.Http;
+
+namespace Monnify.Collections;
+
+/// Returned by both DebitMandateAsync and GetMandateDebitStatusAsync - they share the same shape.
+public sealed class MandateDebitResult
+{
+ /// E.g. PENDING from a fresh debit, or PAID/FAILED from a status check.
+ [JsonPropertyName("transactionStatus")]
+ public string TransactionStatus { get; set; } = string.Empty;
+
+ ///
+ /// A string from DebitMandateAsync, but our own documented sample for
+ /// GetMandateDebitStatusAsync shows this as an empty object ({}) instead -
+ /// deserializes to in that case rather than throwing.
+ ///
+ [JsonConverter(typeof(LenientStringJsonConverter))]
+ [JsonPropertyName("responseMessage")]
+ public string? ResponseMessage { get; set; }
+
+ [JsonPropertyName("transactionReference")]
+ public string TransactionReference { get; set; } = string.Empty;
+
+ [JsonPropertyName("paymentReference")]
+ public string PaymentReference { get; set; } = string.Empty;
+
+ [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
+ [JsonPropertyName("debitAmount")]
+ public decimal DebitAmount { get; set; }
+
+ [JsonPropertyName("narration")]
+ public string Narration { get; set; } = string.Empty;
+
+ [JsonPropertyName("mandateCode")]
+ public string MandateCode { get; set; } = string.Empty;
+}
diff --git a/src/Monnify/Collections/Models/Mandates/MandateListResult.cs b/src/Monnify/Collections/Models/Mandates/MandateListResult.cs
new file mode 100644
index 0000000..cb37780
--- /dev/null
+++ b/src/Monnify/Collections/Models/Mandates/MandateListResult.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Serialization;
+
+namespace Monnify.Collections;
+
+///
+/// The paged result from ListMandatesAsync. Uses its own field names
+/// (pageNumber/pageSize/numberOfElements) rather than the
+/// number/size shapes covers elsewhere in
+/// this SDK, so it isn't reused here.
+///
+public sealed class MandateListResult
+{
+ [JsonPropertyName("content")]
+ public IReadOnlyList Content { get; set; } = Array.Empty();
+
+ [JsonPropertyName("totalElements")]
+ public long TotalElements { get; set; }
+
+ [JsonPropertyName("totalPages")]
+ public int TotalPages { get; set; }
+
+ [JsonPropertyName("pageNumber")]
+ public int PageNumber { get; set; }
+
+ [JsonPropertyName("pageSize")]
+ public int PageSize { get; set; }
+
+ [JsonPropertyName("first")]
+ public bool? IsFirst { get; set; }
+
+ [JsonPropertyName("last")]
+ public bool IsLast { get; set; }
+
+ [JsonPropertyName("numberOfElements")]
+ public int? NumberOfElements { get; set; }
+
+ [JsonPropertyName("empty")]
+ public bool? IsEmpty { get; set; }
+}
diff --git a/src/Monnify/Collections/MonnifyCollectionsClient.cs b/src/Monnify/Collections/MonnifyCollectionsClient.cs
index 0e9b4c5..3c83665 100644
--- a/src/Monnify/Collections/MonnifyCollectionsClient.cs
+++ b/src/Monnify/Collections/MonnifyCollectionsClient.cs
@@ -195,6 +195,82 @@ public Task QueryTransactionAsync(
return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
}
+ public Task CreateMandateAsync(CreateMandateRequest request, CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Mandates.Create)
+ {
+ Content = CreateJsonContent(request),
+ };
+ return SendAsync(httpRequest, cancellationToken);
+ }
+
+ public Task> GetMandatesAsync(string mandateReferences, CancellationToken cancellationToken = default)
+ {
+ RequireValue(mandateReferences, nameof(mandateReferences));
+ var path = $"{MonnifyApiPaths.Collections.Mandates.Base}?mandateReferences={Uri.EscapeDataString(mandateReferences)}";
+ return SendAsync>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
+ }
+
+ public Task DebitMandateAsync(DebitMandateRequest request, CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Mandates.Debit)
+ {
+ Content = CreateJsonContent(request),
+ };
+ return SendAsync(httpRequest, cancellationToken);
+ }
+
+ public Task GetMandateDebitStatusAsync(string paymentReference, CancellationToken cancellationToken = default)
+ {
+ RequireValue(paymentReference, nameof(paymentReference));
+ var path = $"{MonnifyApiPaths.Collections.Mandates.DebitStatus}?paymentReference={Uri.EscapeDataString(paymentReference)}";
+ return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
+ }
+
+ public Task CancelMandateAsync(string mandateCode, CancellationToken cancellationToken = default)
+ {
+ RequireValue(mandateCode, nameof(mandateCode));
+ var path = $"{MonnifyApiPaths.Collections.Mandates.Cancel}/{Uri.EscapeDataString(mandateCode)}";
+ // HttpMethod.Patch isn't available on netstandard2.0; construct it explicitly instead.
+ return SendAsync(new HttpRequestMessage(new HttpMethod("PATCH"), path), cancellationToken);
+ }
+
+ public Task ListMandatesAsync(
+ ListMandatesFilter filter, int page = 0, int limit = 20, CancellationToken cancellationToken = default)
+ {
+ if (filter is null)
+ {
+ throw new ArgumentNullException(nameof(filter));
+ }
+
+ RequireValue(filter.StartDate, nameof(filter.StartDate));
+ RequireValue(filter.EndDate, nameof(filter.EndDate));
+
+ var query = new List
+ {
+ $"startDate={Uri.EscapeDataString(filter.StartDate)}",
+ $"endDate={Uri.EscapeDataString(filter.EndDate)}",
+ $"page={page}",
+ $"limit={limit}",
+ };
+ AppendIfPresent(query, "customerEmail", filter.CustomerEmail);
+ AppendIfPresent(query, "schemeCode", filter.SchemeCode);
+ AppendIfPresent(query, "mandateStatus", filter.MandateStatus);
+
+ var path = $"{MonnifyApiPaths.Collections.Mandates.List}?{string.Join("&", query)}";
+ return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
+ }
+
private static void AppendIfPresent(List query, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
diff --git a/src/Monnify/Http/LenientStringJsonConverter.cs b/src/Monnify/Http/LenientStringJsonConverter.cs
new file mode 100644
index 0000000..4ad4002
--- /dev/null
+++ b/src/Monnify/Http/LenientStringJsonConverter.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Monnify.Http;
+
+///
+/// Some Monnify fields documented as a string come back as an empty object ({}) instead,
+/// depending on endpoint/state (e.g. the direct debit mandate debit-status endpoint's
+/// responseMessage, which is a string from the debit call itself but {} from the
+/// status-check call). Reads a JSON string normally; anything else (object, array, number, bool)
+/// deserializes to rather than throwing.
+///
+internal sealed class LenientStringJsonConverter : JsonConverter
+{
+ public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return reader.GetString();
+ }
+
+ // Properly advances past a {} or [] rather than leaving the reader mid-token, which would
+ // otherwise corrupt the rest of the deserialization.
+ reader.Skip();
+ return null;
+ }
+
+ public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value);
+ }
+ }
+}
diff --git a/src/Monnify/Http/MonnifyApiPaths.cs b/src/Monnify/Http/MonnifyApiPaths.cs
index afa21cf..8c6427e 100644
--- a/src/Monnify/Http/MonnifyApiPaths.cs
+++ b/src/Monnify/Http/MonnifyApiPaths.cs
@@ -57,6 +57,18 @@ internal static class Cards
public const string AuthorizeOtp = "/api/v1/merchant/cards/otp/authorize";
public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize";
}
+
+ internal static class Mandates
+ {
+ public const string Create = "/api/v1/direct-debit/mandate/create";
+ public const string Base = "/api/v1/direct-debit/mandate/";
+ public const string Debit = "/api/v1/direct-debit/mandate/debit";
+ public const string DebitStatus = "/api/v1/direct-debit/mandate/debit-status";
+ public const string Cancel = "/api/v1/direct-debit/mandate/cancel-mandate";
+
+ /// Plural - a different base path than every other mandate endpoint above.
+ public const string List = "/api/v1/direct-debit/mandates";
+ }
}
internal static class Disbursements
diff --git a/tests/Monnify.Tests/Collections/Mandates/MonnifyCollectionsClientMandatesTests.cs b/tests/Monnify.Tests/Collections/Mandates/MonnifyCollectionsClientMandatesTests.cs
new file mode 100644
index 0000000..e072933
--- /dev/null
+++ b/tests/Monnify.Tests/Collections/Mandates/MonnifyCollectionsClientMandatesTests.cs
@@ -0,0 +1,299 @@
+using System.Net;
+using Monnify.Collections;
+using Monnify.Tests.TestUtilities;
+
+namespace Monnify.Tests.Collections.Mandates;
+
+// Payloads below are from our own documented samples for these endpoints; see
+// docs/COMPATIBILITY.md for sandbox-verification status.
+public class MonnifyCollectionsClientMandatesTests
+{
+ private static MonnifyCollectionsClient CreateClient(FakeHttpMessageHandler handler) =>
+ new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") });
+
+ [Fact]
+ public async Task CreateMandateAsync_SendsPostWithJsonBody_AndDeserializesResult()
+ {
+ var handler = new FakeHttpMessageHandler();
+ handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
+ { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
+ "responseBody": {
+ "responseMessage": "Your request for creating a mandate is submitted. An authorization instruction will be sent to the customer email.",
+ "mandateReference": "unique_ref3_02s600972",
+ "mandateCode": "MTDD|01HY8W3FBKHTFBZP9DQ9KNHR1G",
+ "mandateStatus": "INITIATED",
+ "redirectUrl": "https://my-merchants-page.com/transaction/confirm"
+ } }
+ """));
+ var client = CreateClient(handler);
+
+ var result = await client.CreateMandateAsync(new CreateMandateRequest
+ {
+ ContractCode = "4934121686",
+ MandateReference = "unique_ref3_02s600972",
+ MandateAmount = 50000,
+ AutoRenew = false,
+ CustomerCancellation = true,
+ CustomerName = "Ankit Kushwaha",
+ CustomerPhoneNumber = "1234567890",
+ CustomerEmailAddress = "test@moniepoint.com",
+ CustomerAddress = "123 Example Street, City, Country",
+ CustomerAccountNumber = "0051762787",
+ CustomerAccountBankCode = "044",
+ MandateDescription = "Subscription Fee",
+ MandateStartDate = "2024-12-19T10:15:30",
+ MandateEndDate = "2025-12-19T10:15:30",
+ RedirectUrl = "https://my-merchants-page.com/direct-debit/success",
+ });
+
+ Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
+ Assert.Equal("/api/v1/direct-debit/mandate/create", handler.Requests[0].RequestUri!.AbsolutePath);
+ Assert.Contains("\"mandateReference\":\"unique_ref3_02s600972\"", handler.RequestBodies[0]);
+ Assert.Equal("INITIATED", result.MandateStatus);
+ Assert.Equal("MTDD|01HY8W3FBKHTFBZP9DQ9KNHR1G", result.MandateCode);
+ Assert.Equal("https://my-merchants-page.com/transaction/confirm", result.RedirectUrl);
+ }
+
+ [Fact]
+ public async Task CreateMandateAsync_NullRequest_Throws()
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(() => client.CreateMandateAsync(null!));
+ }
+
+ [Fact]
+ public async Task GetMandatesAsync_SendsGetWithReferenceQueryParam_AndDeserializesArray()
+ {
+ var handler = new FakeHttpMessageHandler();
+ handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
+ { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
+ "responseBody": [
+ {
+ "mandateCode": "MTDD|01HY8WMN8JYKDRJC67QPQVS1N0",
+ "externalMandateReference": "unique_ref3_02gs600s972",
+ "startDate": "2024-05-19T09:15:30.000+0000",
+ "endDate": "2024-09-22T09:15:30.000+0000",
+ "mandateStatus": "ACTIVE",
+ "mandateAmount": 50000,
+ "contractCode": "4934121686",
+ "autoRenew": false,
+ "customerPhoneNumber": "1234567890",
+ "customerEmailAddress": "test@moniepoint.com",
+ "customerAddress": "123 Example Street, City, Country",
+ "customerName": "Ankit Kushwaha",
+ "customerAccountName": "Ankit Kushwaha",
+ "customerAccountNumber": "0051762787",
+ "customerAccountBankCode": "998",
+ "mandateDescription": "Subscription Fee",
+ "debitAmount": null,
+ "authorizationMessage": "Request Ankit Kushwaha to kindly proceed with a token payment.",
+ "authorizationLink": "https://paylink.monnify.com/mandate-auth/abc"
+ }
+ ] }
+ """));
+ var client = CreateClient(handler);
+
+ var result = await client.GetMandatesAsync("unique_ref3_02gs600s972");
+
+ Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
+ Assert.Equal("/api/v1/direct-debit/mandate/", handler.Requests[0].RequestUri!.AbsolutePath);
+ Assert.Equal("mandateReferences=unique_ref3_02gs600s972", handler.Requests[0].RequestUri!.Query.TrimStart('?'));
+ Assert.Single(result);
+ Assert.Equal("ACTIVE", result[0].MandateStatus);
+ Assert.Null(result[0].DebitAmount);
+ Assert.Equal("Ankit Kushwaha", result[0].CustomerAccountName);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task GetMandatesAsync_MissingReference_Throws(string? mandateReferences)
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(() => client.GetMandatesAsync(mandateReferences!));
+ }
+
+ [Fact]
+ public async Task DebitMandateAsync_SendsPostWithJsonBody_AndDeserializesStringResponseMessage()
+ {
+ var handler = new FakeHttpMessageHandler();
+ handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
+ { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
+ "responseBody": {
+ "transactionStatus": "PENDING",
+ "responseMessage": "Debit mandate request accepted for processing",
+ "transactionReference": "MNFY|20240519180055|000001",
+ "paymentReference": "PR1234567991002",
+ "debitAmount": 1000,
+ "narration": "Payment for Services",
+ "mandateCode": "MTDD|01HY8WMN8JYKDRJC67QPQVS1N0"
+ } }
+ """));
+ var client = CreateClient(handler);
+
+ var result = await client.DebitMandateAsync(new DebitMandateRequest
+ {
+ PaymentReference = "PR1234567991002",
+ MandateCode = "MTDD|01HY8WMN8JYKDRJC67QPQVS1N0",
+ DebitAmount = 1000,
+ Narration = "Payment for Services",
+ CustomerEmail = "ahsan.saleem@gmail.com",
+ IncomeSplitConfig = new[]
+ {
+ new IncomeSplitConfig
+ {
+ SubAccountCode = "MFY_SUB_319452883228",
+ FeePercentage = 10.5m,
+ SplitAmount = 20,
+ SplitPercentage = 20,
+ FeeBearer = true,
+ },
+ },
+ });
+
+ Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
+ Assert.Equal("/api/v1/direct-debit/mandate/debit", handler.Requests[0].RequestUri!.AbsolutePath);
+ Assert.Contains("\"subAccountCode\":\"MFY_SUB_319452883228\"", handler.RequestBodies[0]);
+ Assert.Equal("PENDING", result.TransactionStatus);
+ Assert.Equal("Debit mandate request accepted for processing", result.ResponseMessage);
+ }
+
+ [Fact]
+ public async Task DebitMandateAsync_NullRequest_Throws()
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(() => client.DebitMandateAsync(null!));
+ }
+
+ [Fact]
+ public async Task GetMandateDebitStatusAsync_ResponseMessageIsEmptyObject_DeserializesToNullInsteadOfThrowing()
+ {
+ var handler = new FakeHttpMessageHandler();
+ // Per our own documented sample: responseMessage is an empty object here, not a string
+ // like it is on DebitMandateAsync's response.
+ handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
+ { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
+ "responseBody": {
+ "transactionStatus": "PAID",
+ "responseMessage": {},
+ "transactionReference": "MNFY|20240519180055|000001",
+ "paymentReference": "PR1234567991002",
+ "debitAmount": 1000,
+ "narration": "Payment for Services",
+ "mandateCode": "MTDD|01HY8WMN8JYKDRJC67QPQVS1N0"
+ } }
+ """));
+ var client = CreateClient(handler);
+
+ var result = await client.GetMandateDebitStatusAsync("PR1234567991002");
+
+ Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
+ Assert.Equal("/api/v1/direct-debit/mandate/debit-status", handler.Requests[0].RequestUri!.AbsolutePath);
+ Assert.Equal("paymentReference=PR1234567991002", handler.Requests[0].RequestUri!.Query.TrimStart('?'));
+ Assert.Equal("PAID", result.TransactionStatus);
+ Assert.Null(result.ResponseMessage);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task GetMandateDebitStatusAsync_MissingReference_Throws(string? paymentReference)
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(() => client.GetMandateDebitStatusAsync(paymentReference!));
+ }
+
+ [Fact]
+ public async Task CancelMandateAsync_SendsPatchWithMandateCodeInPath()
+ {
+ var handler = new FakeHttpMessageHandler();
+ handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
+ { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
+ "responseBody": {
+ "responseMessage": "Mandate has been requested to be cancelled.",
+ "mandateReference": "unique_ref3_02s600972",
+ "mandateCode": "MTDD|01HY8W3FBKHTFBZP9DQ9KNHR1G",
+ "mandateStatus": "ACTIVE"
+ } }
+ """));
+ var client = CreateClient(handler);
+
+ var result = await client.CancelMandateAsync("MTDD|01HY8W3FBKHTFBZP9DQ9KNHR1G");
+
+ Assert.Equal("PATCH", handler.Requests[0].Method.Method);
+ Assert.Equal(
+ "/api/v1/direct-debit/mandate/cancel-mandate/MTDD%7C01HY8W3FBKHTFBZP9DQ9KNHR1G",
+ handler.Requests[0].RequestUri!.AbsolutePath);
+ Assert.Equal("ACTIVE", result.MandateStatus);
+ Assert.Null(result.RedirectUrl);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task CancelMandateAsync_MissingMandateCode_Throws(string? mandateCode)
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(() => client.CancelMandateAsync(mandateCode!));
+ }
+
+ [Fact]
+ public async Task ListMandatesAsync_SendsGetWithRequiredDateRangeAndOptionalFilters_AndDeserializesPagedResult()
+ {
+ var handler = new FakeHttpMessageHandler();
+ handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
+ { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
+ "responseBody": {
+ "content": [
+ { "mandateCode": "MTDD|01HY8WMN8JYKDRJC67QPQVS1N0", "externalMandateReference": "unique_ref3_02gs600s972",
+ "startDate": "2024-05-19T09:15:30.000+0000", "endDate": "2024-09-22T09:15:30.000+0000",
+ "mandateStatus": "ACTIVE", "mandateAmount": 50000, "contractCode": "4934121686", "autoRenew": false,
+ "customerPhoneNumber": "1234567890", "customerEmailAddress": "test@moniepoint.com",
+ "customerAddress": "123 Example Street, City, Country", "customerName": "Ankit Kushwaha",
+ "customerAccountName": "Ankit Kushwaha", "customerAccountNumber": "0051762787",
+ "customerAccountBankCode": "998", "mandateDescription": "Subscription Fee", "debitAmount": null,
+ "authorizationMessage": "...", "authorizationLink": "https://paylink.monnify.com/mandate-auth/abc" }
+ ],
+ "totalElements": 42, "totalPages": 3, "pageNumber": 0, "pageSize": 20,
+ "first": true, "last": false, "numberOfElements": 20, "empty": false
+ } }
+ """));
+ var client = CreateClient(handler);
+
+ var result = await client.ListMandatesAsync(new ListMandatesFilter
+ {
+ StartDate = "2024-01-01T00:00:00",
+ EndDate = "2024-03-31T00:00:00",
+ MandateStatus = "ACTIVE",
+ });
+
+ Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
+ Assert.Equal("/api/v1/direct-debit/mandates", handler.Requests[0].RequestUri!.AbsolutePath);
+ Assert.Equal(
+ "startDate=2024-01-01T00%3A00%3A00&endDate=2024-03-31T00%3A00%3A00&page=0&limit=20&mandateStatus=ACTIVE",
+ handler.Requests[0].RequestUri!.Query.TrimStart('?'));
+ Assert.Single(result.Content);
+ Assert.Equal(42, result.TotalElements);
+ Assert.Equal(3, result.TotalPages);
+ Assert.Equal(20, result.NumberOfElements);
+ Assert.True(result.IsFirst);
+ Assert.False(result.IsLast);
+ }
+
+ [Fact]
+ public async Task ListMandatesAsync_NullFilter_Throws()
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(() => client.ListMandatesAsync(null!));
+ }
+
+ [Theory]
+ [InlineData("", "2024-03-31T00:00:00")]
+ [InlineData("2024-01-01T00:00:00", "")]
+ public async Task ListMandatesAsync_MissingRequiredDateRange_Throws(string startDate, string endDate)
+ {
+ var client = CreateClient(new FakeHttpMessageHandler());
+ await Assert.ThrowsAsync(
+ () => client.ListMandatesAsync(new ListMandatesFilter { StartDate = startDate, EndDate = endDate }));
+ }
+}