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
13 changes: 7 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
51 changes: 44 additions & 7 deletions src/Monnify/Collections/IMonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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
/// debit a card and <see cref="DebitMandateAsync"/> 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
/// <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.
/// Query <see cref="GetTransactionAsync"/> or <see cref="GetMandateDebitStatusAsync"/> 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.
/// </remarks>
public interface IMonnifyCollectionsClient
{
Expand Down Expand Up @@ -76,4 +77,40 @@ Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
/// <summary>Gets the status of a transaction by either its Monnify transaction reference or the merchant's payment reference.</summary>
Task<Transaction> QueryTransactionAsync(
string? transactionReference = null, string? paymentReference = null, CancellationToken cancellationToken = default);

/// <summary>
/// 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 <see cref="MandateActionResult.RedirectUrl"/> or the
/// <see cref="Mandate.AuthorizationLink"/> returned by <see cref="GetMandatesAsync"/>.
/// </summary>
Task<MandateActionResult> CreateMandateAsync(CreateMandateRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
Task<IReadOnlyList<Mandate>> GetMandatesAsync(string mandateReferences, CancellationToken cancellationToken = default);

/// <summary>
/// Debits a single payment from an active mandate. See the remarks on
/// <see cref="IMonnifyCollectionsClient"/> for the safe retry pattern after an ambiguous
/// failure.
/// </summary>
Task<MandateDebitResult> DebitMandateAsync(DebitMandateRequest request, CancellationToken cancellationToken = default);

/// <summary>Checks the status of a previous <see cref="DebitMandateAsync"/> call by its payment reference.</summary>
Task<MandateDebitResult> GetMandateDebitStatusAsync(string paymentReference, CancellationToken cancellationToken = default);

/// <summary>Requests cancellation of a mandate.</summary>
Task<MandateActionResult> CancelMandateAsync(string mandateCode, CancellationToken cancellationToken = default);

/// <summary>
/// Lists mandates created or initiated by the merchant. <paramref name="filter"/>'s
/// <see cref="ListMandatesFilter.StartDate"/>/<see cref="ListMandatesFilter.EndDate"/> are
/// required and the range between them must not exceed 90 days.
/// </summary>
Task<MandateListResult> ListMandatesAsync(
ListMandatesFilter filter, int page = 0, int limit = 20, CancellationToken cancellationToken = default);
}
64 changes: 64 additions & 0 deletions src/Monnify/Collections/Models/Mandates/CreateMandateRequest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Your own reference to identify this mandate - returned back as <see cref="Mandate.MandateReference"/>.</summary>
[JsonPropertyName("mandateReference")]
public string MandateReference { get; set; } = string.Empty;

/// <summary>Total lifetime amount debitable on the mandate.</summary>
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
[JsonPropertyName("mandateAmount")]
public decimal MandateAmount { get; set; }

/// <summary>Whether the mandate renews automatically once it reaches <see cref="MandateEndDate"/>.</summary>
[JsonPropertyName("autoRenew")]
public bool AutoRenew { get; set; }

/// <summary>Whether the customer is allowed to cancel this mandate themselves.</summary>
[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;

/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c>.</summary>
[JsonPropertyName("mandateStartDate")]
public string MandateStartDate { get; set; } = string.Empty;

/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c>.</summary>
[JsonPropertyName("mandateEndDate")]
public string MandateEndDate { get; set; } = string.Empty;

/// <summary>Where to redirect the customer to after they complete authorization.</summary>
[JsonPropertyName("redirectUrl")]
public string? RedirectUrl { get; set; }

/// <summary>The amount to be debited per periodic debit, if different from the lifetime <see cref="MandateAmount"/>.</summary>
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
[JsonPropertyName("debitAmount")]
public decimal? DebitAmount { get; set; }
}
27 changes: 27 additions & 0 deletions src/Monnify/Collections/Models/Mandates/DebitMandateRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

public sealed class DebitMandateRequest
{
/// <summary>Your own reference to identify this single debit.</summary>
[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;

/// <summary>How to split this payment among sub-accounts, if at all.</summary>
[JsonPropertyName("incomeSplitConfig")]
public IReadOnlyList<IncomeSplitConfig>? IncomeSplitConfig { get; set; }
}
19 changes: 19 additions & 0 deletions src/Monnify/Collections/Models/Mandates/ListMandatesFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Monnify.Collections;

/// <summary>Optional filters for <see cref="IMonnifyCollectionsClient.ListMandatesAsync"/>. <see cref="StartDate"/>/<see cref="EndDate"/> are required by the endpoint.</summary>
public sealed class ListMandatesFilter
{
/// <summary>Required. Format: <c>YYYY-MM-DDTHH:MM:SS</c>. The range with <see cref="EndDate"/> must not exceed 90 days.</summary>
public string StartDate { get; set; } = string.Empty;

/// <summary>Required. Format: <c>YYYY-MM-DDTHH:MM:SS</c>. The range with <see cref="StartDate"/> must not exceed 90 days.</summary>
public string EndDate { get; set; } = string.Empty;

public string? CustomerEmail { get; set; }

/// <summary>Direct debit scheme code, e.g. <c>ADD</c>.</summary>
public string? SchemeCode { get; set; }

/// <summary>One of <c>PENDING</c>, <c>ACTIVE</c>, <c>FAILED</c>, <c>CANCELLED</c>, <c>EXPIRED</c>.</summary>
public string? MandateStatus { get; set; }
}
92 changes: 92 additions & 0 deletions src/Monnify/Collections/Models/Mandates/Mandate.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The merchant-generated reference supplied as <c>mandateReference</c> at creation time. Our
/// docs' sample for this endpoint shows the field as <c>externalMandateReference</c>; the real
/// sandbox returns <c>mandateReference</c> instead.
/// </summary>
[JsonPropertyName("mandateReference")]
public string MandateReference { get; set; } = string.Empty;

/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c> (with milliseconds and a timezone offset on responses).</summary>
[JsonPropertyName("startDate")]
public string StartDate { get; set; } = string.Empty;

/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c> (with milliseconds and a timezone offset on responses).</summary>
[JsonPropertyName("endDate")]
public string EndDate { get; set; } = string.Empty;

/// <summary>
/// E.g. <c>ACTIVE</c>, <c>FAILED</c>, <c>CANCELLED</c>, <c>EXPIRED</c>. Sandbox-observed values
/// include <c>PENDING_AUTHORIZATION</c> as well, which isn't in our docs' status list
/// (<c>PENDING</c>, <c>ACTIVE</c>, <c>FAILED</c>, <c>CANCELLED</c>, <c>EXPIRED</c>) - treat that
/// list as non-exhaustive.
/// </summary>
[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;

/// <summary>The account name Monnify resolved for <see cref="CustomerAccountNumber"/>, distinct from <see cref="CustomerName"/>.</summary>
[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;

/// <summary>The amount to be debited per periodic debit, if different from <see cref="MandateAmount"/> (the lifetime total).</summary>
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
[JsonPropertyName("debitAmount")]
public decimal? DebitAmount { get; set; }

/// <summary>Human-readable instructions for the customer to authorize the mandate (a token transfer).</summary>
[JsonPropertyName("authorizationMessage")]
public string? AuthorizationMessage { get; set; }

/// <summary>A hosted page where the customer can complete authorization.</summary>
[JsonPropertyName("authorizationLink")]
public string? AuthorizationLink { get; set; }

/// <summary>
/// A human-readable status message (e.g. <c>"Active Mandate"</c>).
/// </summary>
[JsonPropertyName("responseMessage")]
public string? ResponseMessage { get; set; }

/// <summary>The direct debit scheme, e.g. <c>NDD</c>.</summary>
[JsonPropertyName("schemeCode")]
public string? SchemeCode { get; set; }
}
24 changes: 24 additions & 0 deletions src/Monnify/Collections/Models/Mandates/MandateActionResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

/// <summary>Returned by both <c>CreateMandateAsync</c> and <c>CancelMandateAsync</c> - they share the same shape, minus <see cref="RedirectUrl"/> on cancel.</summary>
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;

/// <summary>E.g. <c>INITIATED</c> after creation, or the mandate's prior status on cancellation.</summary>
[JsonPropertyName("mandateStatus")]
public string MandateStatus { get; set; } = string.Empty;

/// <summary>Only populated by <c>CreateMandateAsync</c>.</summary>
[JsonPropertyName("redirectUrl")]
public string? RedirectUrl { get; set; }
}
37 changes: 37 additions & 0 deletions src/Monnify/Collections/Models/Mandates/MandateDebitResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
using Monnify.Http;

namespace Monnify.Collections;

/// <summary>Returned by both <c>DebitMandateAsync</c> and <c>GetMandateDebitStatusAsync</c> - they share the same shape.</summary>
public sealed class MandateDebitResult
{
/// <summary>E.g. <c>PENDING</c> from a fresh debit, or <c>PAID</c>/<c>FAILED</c> from a status check.</summary>
[JsonPropertyName("transactionStatus")]
public string TransactionStatus { get; set; } = string.Empty;

/// <summary>
/// A string from <c>DebitMandateAsync</c>, but our own documented sample for
/// <c>GetMandateDebitStatusAsync</c> shows this as an empty object (<c>{}</c>) instead -
/// deserializes to <see langword="null"/> in that case rather than throwing.
/// </summary>
[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;
}
Loading
Loading