Skip to content

Commit d8f772d

Browse files
authored
Merge pull request #11 from Monnify/dev
feat: add direct debit mandates client
2 parents 17013f2 + dc65517 commit d8f772d

14 files changed

Lines changed: 785 additions & 14 deletions

CHANGELOG.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ own version is independent of Monnify's API versioning.
2020
## [Unreleased]
2121

2222
### Added
23-
- `IMonnifyCollectionsClient`: `ChargeAsync`, `AuthorizeOtpAsync`, `Authorize3dsAsync` for direct
24-
card charges. The whole client now registers with automatic retry disabled, same reasoning as
25-
Disbursements/Bills, since `ChargeAsync` directly debits a card. See docs/COMPATIBILITY.md for
26-
sandbox-verification notes.
27-
- Corrected `InitiateBankTransferAsync`'s doc note: the real response field is `ussdPayment`, not
28-
`ussdCode` as our simplified doc sample shows (the SDK already had this right).
23+
- `IMonnifyCollectionsClient`: direct debit mandates - `CreateMandateAsync`, `GetMandatesAsync`,
24+
`DebitMandateAsync`, `GetMandateDebitStatusAsync`, `CancelMandateAsync`, `ListMandatesAsync`.
25+
Sandbox testing surfaced several real discrepancies with our own docs (wrong field name for the
26+
merchant reference, undocumented fields, an extra `mandateStatus` value, a paging shape missing
27+
fields our sample shows) - see docs/COMPATIBILITY.md for each. Also added
28+
`LenientStringJsonConverter` for `GetMandateDebitStatusAsync`'s `responseMessage`, which our docs
29+
sample shows as an empty object instead of a string there.
2930

3031
## [0.1.0] - 2026-06-29
3132

docs/COMPATIBILITY.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ Status legend:
2828
| `IMonnifyCollectionsClient.GetTransactionAsync` | `GET /api/v2/transactions/{transactionReference}` | **Implemented** | Amount fields are quoted strings; `decimal` via `JsonNumberHandling.AllowReadingFromString` |
2929
| `IMonnifyCollectionsClient.QueryTransactionAsync` | `GET /api/v2/merchant/transactions/query?transactionReference=&paymentReference=` | **Implemented** | Same response shape as `GetTransactionAsync`; requires at least one query parameter |
3030
| *(Collections — sub-accounts/splitting)* | TBD | Planned | |
31-
| *(Collections — direct debit/mandates)* | TBD | Planned | |
31+
| `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 |
32+
| `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 |
33+
| `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 |
34+
| `IMonnifyCollectionsClient.GetMandateDebitStatusAsync` | `GET /api/v1/direct-debit/mandate/debit-status?paymentReference=` | **Implemented** | `responseMessage` can be `{}` instead of a string - handled via `LenientStringJsonConverter` |
35+
| `IMonnifyCollectionsClient.CancelMandateAsync` | `PATCH /api/v1/direct-debit/mandate/cancel-mandate/{mandateCode}` | **Implemented** | Sandbox-verified against a real mandate created in this session |
36+
| `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 |
3237
| *(Collections — card tokenization)* | TBD | Planned | |
3338
| *(Collections — Payment Links)* | N/A | Out of scope | Dashboard-only feature, no API |
3439
| `IMonnifyDisbursementsClient.InitiateSingleTransferAsync` | `POST /api/v2/disbursements/single` | **Implemented** | Requires Transfer feature activation (sales@monnify.com); automatic retry disabled |

src/Monnify/Collections/IMonnifyCollectionsClient.cs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ namespace Monnify.Collections;
44

55
/// <remarks>
66
/// Registered with automatic HTTP retry disabled: <see cref="ChargeAsync"/> directly attempts to
7-
/// debit a card, so an ambiguous failure (timeout, network error, 5xx) must not be blindly resent
8-
/// with the same details - that risks a double charge, same reasoning as
7+
/// debit a card and <see cref="DebitMandateAsync"/> directly debits a mandate, so an ambiguous
8+
/// failure (timeout, network error, 5xx) on either must not be blindly resent with the same
9+
/// details - that risks a double charge, same reasoning as
910
/// <see cref="Disbursements.IMonnifyDisbursementsClient"/> and <see cref="Bills.IMonnifyBillsClient"/>.
10-
/// Query <see cref="GetTransactionAsync"/> with the same <c>transactionReference</c> instead. The
11-
/// other methods on this client don't move money directly (they set up a payment instrument the
12-
/// customer still has to complete), so this is a more conservative retry stance than they
13-
/// strictly need, traded for keeping every collection method - including card charges - on one
14-
/// client.
11+
/// Query <see cref="GetTransactionAsync"/> or <see cref="GetMandateDebitStatusAsync"/> with the
12+
/// same reference instead. Most other methods on this client don't move money directly (they set
13+
/// up a payment instrument the customer still has to complete), so this is a more conservative
14+
/// retry stance than they strictly need, traded for keeping every collection method - including
15+
/// card charges and mandate debits - on one client.
1516
/// </remarks>
1617
public interface IMonnifyCollectionsClient
1718
{
@@ -76,4 +77,40 @@ Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
7677
/// <summary>Gets the status of a transaction by either its Monnify transaction reference or the merchant's payment reference.</summary>
7778
Task<Transaction> QueryTransactionAsync(
7879
string? transactionReference = null, string? paymentReference = null, CancellationToken cancellationToken = default);
80+
81+
/// <summary>
82+
/// Initiates a direct debit mandate - a recurring authorization to debit a customer's bank
83+
/// account. The mandate isn't active until the customer completes authorization (a token
84+
/// transfer) via <see cref="MandateActionResult.RedirectUrl"/> or the
85+
/// <see cref="Mandate.AuthorizationLink"/> returned by <see cref="GetMandatesAsync"/>.
86+
/// </summary>
87+
Task<MandateActionResult> CreateMandateAsync(CreateMandateRequest request, CancellationToken cancellationToken = default);
88+
89+
/// <summary>
90+
/// Looks up one or more mandates by reference. Despite the singular use case, the endpoint's
91+
/// query parameter and response are both plural - pass a single reference or several
92+
/// comma-separated, and always get a list back.
93+
/// </summary>
94+
Task<IReadOnlyList<Mandate>> GetMandatesAsync(string mandateReferences, CancellationToken cancellationToken = default);
95+
96+
/// <summary>
97+
/// Debits a single payment from an active mandate. See the remarks on
98+
/// <see cref="IMonnifyCollectionsClient"/> for the safe retry pattern after an ambiguous
99+
/// failure.
100+
/// </summary>
101+
Task<MandateDebitResult> DebitMandateAsync(DebitMandateRequest request, CancellationToken cancellationToken = default);
102+
103+
/// <summary>Checks the status of a previous <see cref="DebitMandateAsync"/> call by its payment reference.</summary>
104+
Task<MandateDebitResult> GetMandateDebitStatusAsync(string paymentReference, CancellationToken cancellationToken = default);
105+
106+
/// <summary>Requests cancellation of a mandate.</summary>
107+
Task<MandateActionResult> CancelMandateAsync(string mandateCode, CancellationToken cancellationToken = default);
108+
109+
/// <summary>
110+
/// Lists mandates created or initiated by the merchant. <paramref name="filter"/>'s
111+
/// <see cref="ListMandatesFilter.StartDate"/>/<see cref="ListMandatesFilter.EndDate"/> are
112+
/// required and the range between them must not exceed 90 days.
113+
/// </summary>
114+
Task<MandateListResult> ListMandatesAsync(
115+
ListMandatesFilter filter, int page = 0, int limit = 20, CancellationToken cancellationToken = default);
79116
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Monnify.Collections;
4+
5+
public sealed class CreateMandateRequest
6+
{
7+
[JsonPropertyName("contractCode")]
8+
public string ContractCode { get; set; } = string.Empty;
9+
10+
/// <summary>Your own reference to identify this mandate - returned back as <see cref="Mandate.MandateReference"/>.</summary>
11+
[JsonPropertyName("mandateReference")]
12+
public string MandateReference { get; set; } = string.Empty;
13+
14+
/// <summary>Total lifetime amount debitable on the mandate.</summary>
15+
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
16+
[JsonPropertyName("mandateAmount")]
17+
public decimal MandateAmount { get; set; }
18+
19+
/// <summary>Whether the mandate renews automatically once it reaches <see cref="MandateEndDate"/>.</summary>
20+
[JsonPropertyName("autoRenew")]
21+
public bool AutoRenew { get; set; }
22+
23+
/// <summary>Whether the customer is allowed to cancel this mandate themselves.</summary>
24+
[JsonPropertyName("customerCancellation")]
25+
public bool CustomerCancellation { get; set; }
26+
27+
[JsonPropertyName("customerName")]
28+
public string CustomerName { get; set; } = string.Empty;
29+
30+
[JsonPropertyName("customerPhoneNumber")]
31+
public string CustomerPhoneNumber { get; set; } = string.Empty;
32+
33+
[JsonPropertyName("customerEmailAddress")]
34+
public string CustomerEmailAddress { get; set; } = string.Empty;
35+
36+
[JsonPropertyName("customerAddress")]
37+
public string CustomerAddress { get; set; } = string.Empty;
38+
39+
[JsonPropertyName("customerAccountNumber")]
40+
public string CustomerAccountNumber { get; set; } = string.Empty;
41+
42+
[JsonPropertyName("customerAccountBankCode")]
43+
public string CustomerAccountBankCode { get; set; } = string.Empty;
44+
45+
[JsonPropertyName("mandateDescription")]
46+
public string MandateDescription { get; set; } = string.Empty;
47+
48+
/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c>.</summary>
49+
[JsonPropertyName("mandateStartDate")]
50+
public string MandateStartDate { get; set; } = string.Empty;
51+
52+
/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c>.</summary>
53+
[JsonPropertyName("mandateEndDate")]
54+
public string MandateEndDate { get; set; } = string.Empty;
55+
56+
/// <summary>Where to redirect the customer to after they complete authorization.</summary>
57+
[JsonPropertyName("redirectUrl")]
58+
public string? RedirectUrl { get; set; }
59+
60+
/// <summary>The amount to be debited per periodic debit, if different from the lifetime <see cref="MandateAmount"/>.</summary>
61+
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
62+
[JsonPropertyName("debitAmount")]
63+
public decimal? DebitAmount { get; set; }
64+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Monnify.Collections;
4+
5+
public sealed class DebitMandateRequest
6+
{
7+
/// <summary>Your own reference to identify this single debit.</summary>
8+
[JsonPropertyName("paymentReference")]
9+
public string PaymentReference { get; set; } = string.Empty;
10+
11+
[JsonPropertyName("mandateCode")]
12+
public string MandateCode { get; set; } = string.Empty;
13+
14+
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
15+
[JsonPropertyName("debitAmount")]
16+
public decimal DebitAmount { get; set; }
17+
18+
[JsonPropertyName("narration")]
19+
public string Narration { get; set; } = string.Empty;
20+
21+
[JsonPropertyName("customerEmail")]
22+
public string CustomerEmail { get; set; } = string.Empty;
23+
24+
/// <summary>How to split this payment among sub-accounts, if at all.</summary>
25+
[JsonPropertyName("incomeSplitConfig")]
26+
public IReadOnlyList<IncomeSplitConfig>? IncomeSplitConfig { get; set; }
27+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Monnify.Collections;
2+
3+
/// <summary>Optional filters for <see cref="IMonnifyCollectionsClient.ListMandatesAsync"/>. <see cref="StartDate"/>/<see cref="EndDate"/> are required by the endpoint.</summary>
4+
public sealed class ListMandatesFilter
5+
{
6+
/// <summary>Required. Format: <c>YYYY-MM-DDTHH:MM:SS</c>. The range with <see cref="EndDate"/> must not exceed 90 days.</summary>
7+
public string StartDate { get; set; } = string.Empty;
8+
9+
/// <summary>Required. Format: <c>YYYY-MM-DDTHH:MM:SS</c>. The range with <see cref="StartDate"/> must not exceed 90 days.</summary>
10+
public string EndDate { get; set; } = string.Empty;
11+
12+
public string? CustomerEmail { get; set; }
13+
14+
/// <summary>Direct debit scheme code, e.g. <c>ADD</c>.</summary>
15+
public string? SchemeCode { get; set; }
16+
17+
/// <summary>One of <c>PENDING</c>, <c>ACTIVE</c>, <c>FAILED</c>, <c>CANCELLED</c>, <c>EXPIRED</c>.</summary>
18+
public string? MandateStatus { get; set; }
19+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Monnify.Collections;
4+
5+
public sealed class Mandate
6+
{
7+
[JsonPropertyName("mandateCode")]
8+
public string MandateCode { get; set; } = string.Empty;
9+
10+
/// <summary>
11+
/// The merchant-generated reference supplied as <c>mandateReference</c> at creation time. Our
12+
/// docs' sample for this endpoint shows the field as <c>externalMandateReference</c>; the real
13+
/// sandbox returns <c>mandateReference</c> instead.
14+
/// </summary>
15+
[JsonPropertyName("mandateReference")]
16+
public string MandateReference { get; set; } = string.Empty;
17+
18+
/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c> (with milliseconds and a timezone offset on responses).</summary>
19+
[JsonPropertyName("startDate")]
20+
public string StartDate { get; set; } = string.Empty;
21+
22+
/// <summary>Format: <c>YYYY-MM-DDTHH:MM:SS</c> (with milliseconds and a timezone offset on responses).</summary>
23+
[JsonPropertyName("endDate")]
24+
public string EndDate { get; set; } = string.Empty;
25+
26+
/// <summary>
27+
/// E.g. <c>ACTIVE</c>, <c>FAILED</c>, <c>CANCELLED</c>, <c>EXPIRED</c>. Sandbox-observed values
28+
/// include <c>PENDING_AUTHORIZATION</c> as well, which isn't in our docs' status list
29+
/// (<c>PENDING</c>, <c>ACTIVE</c>, <c>FAILED</c>, <c>CANCELLED</c>, <c>EXPIRED</c>) - treat that
30+
/// list as non-exhaustive.
31+
/// </summary>
32+
[JsonPropertyName("mandateStatus")]
33+
public string MandateStatus { get; set; } = string.Empty;
34+
35+
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
36+
[JsonPropertyName("mandateAmount")]
37+
public decimal MandateAmount { get; set; }
38+
39+
[JsonPropertyName("contractCode")]
40+
public string ContractCode { get; set; } = string.Empty;
41+
42+
[JsonPropertyName("autoRenew")]
43+
public bool AutoRenew { get; set; }
44+
45+
[JsonPropertyName("customerPhoneNumber")]
46+
public string CustomerPhoneNumber { get; set; } = string.Empty;
47+
48+
[JsonPropertyName("customerEmailAddress")]
49+
public string CustomerEmailAddress { get; set; } = string.Empty;
50+
51+
[JsonPropertyName("customerAddress")]
52+
public string CustomerAddress { get; set; } = string.Empty;
53+
54+
[JsonPropertyName("customerName")]
55+
public string CustomerName { get; set; } = string.Empty;
56+
57+
/// <summary>The account name Monnify resolved for <see cref="CustomerAccountNumber"/>, distinct from <see cref="CustomerName"/>.</summary>
58+
[JsonPropertyName("customerAccountName")]
59+
public string? CustomerAccountName { get; set; }
60+
61+
[JsonPropertyName("customerAccountNumber")]
62+
public string CustomerAccountNumber { get; set; } = string.Empty;
63+
64+
[JsonPropertyName("customerAccountBankCode")]
65+
public string CustomerAccountBankCode { get; set; } = string.Empty;
66+
67+
[JsonPropertyName("mandateDescription")]
68+
public string MandateDescription { get; set; } = string.Empty;
69+
70+
/// <summary>The amount to be debited per periodic debit, if different from <see cref="MandateAmount"/> (the lifetime total).</summary>
71+
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
72+
[JsonPropertyName("debitAmount")]
73+
public decimal? DebitAmount { get; set; }
74+
75+
/// <summary>Human-readable instructions for the customer to authorize the mandate (a token transfer).</summary>
76+
[JsonPropertyName("authorizationMessage")]
77+
public string? AuthorizationMessage { get; set; }
78+
79+
/// <summary>A hosted page where the customer can complete authorization.</summary>
80+
[JsonPropertyName("authorizationLink")]
81+
public string? AuthorizationLink { get; set; }
82+
83+
/// <summary>
84+
/// A human-readable status message (e.g. <c>"Active Mandate"</c>).
85+
/// </summary>
86+
[JsonPropertyName("responseMessage")]
87+
public string? ResponseMessage { get; set; }
88+
89+
/// <summary>The direct debit scheme, e.g. <c>NDD</c>.</summary>
90+
[JsonPropertyName("schemeCode")]
91+
public string? SchemeCode { get; set; }
92+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Monnify.Collections;
4+
5+
/// <summary>Returned by both <c>CreateMandateAsync</c> and <c>CancelMandateAsync</c> - they share the same shape, minus <see cref="RedirectUrl"/> on cancel.</summary>
6+
public sealed class MandateActionResult
7+
{
8+
[JsonPropertyName("responseMessage")]
9+
public string ResponseMessage { get; set; } = string.Empty;
10+
11+
[JsonPropertyName("mandateReference")]
12+
public string MandateReference { get; set; } = string.Empty;
13+
14+
[JsonPropertyName("mandateCode")]
15+
public string MandateCode { get; set; } = string.Empty;
16+
17+
/// <summary>E.g. <c>INITIATED</c> after creation, or the mandate's prior status on cancellation.</summary>
18+
[JsonPropertyName("mandateStatus")]
19+
public string MandateStatus { get; set; } = string.Empty;
20+
21+
/// <summary>Only populated by <c>CreateMandateAsync</c>.</summary>
22+
[JsonPropertyName("redirectUrl")]
23+
public string? RedirectUrl { get; set; }
24+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Text.Json.Serialization;
2+
using Monnify.Http;
3+
4+
namespace Monnify.Collections;
5+
6+
/// <summary>Returned by both <c>DebitMandateAsync</c> and <c>GetMandateDebitStatusAsync</c> - they share the same shape.</summary>
7+
public sealed class MandateDebitResult
8+
{
9+
/// <summary>E.g. <c>PENDING</c> from a fresh debit, or <c>PAID</c>/<c>FAILED</c> from a status check.</summary>
10+
[JsonPropertyName("transactionStatus")]
11+
public string TransactionStatus { get; set; } = string.Empty;
12+
13+
/// <summary>
14+
/// A string from <c>DebitMandateAsync</c>, but our own documented sample for
15+
/// <c>GetMandateDebitStatusAsync</c> shows this as an empty object (<c>{}</c>) instead -
16+
/// deserializes to <see langword="null"/> in that case rather than throwing.
17+
/// </summary>
18+
[JsonConverter(typeof(LenientStringJsonConverter))]
19+
[JsonPropertyName("responseMessage")]
20+
public string? ResponseMessage { get; set; }
21+
22+
[JsonPropertyName("transactionReference")]
23+
public string TransactionReference { get; set; } = string.Empty;
24+
25+
[JsonPropertyName("paymentReference")]
26+
public string PaymentReference { get; set; } = string.Empty;
27+
28+
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
29+
[JsonPropertyName("debitAmount")]
30+
public decimal DebitAmount { get; set; }
31+
32+
[JsonPropertyName("narration")]
33+
public string Narration { get; set; } = string.Empty;
34+
35+
[JsonPropertyName("mandateCode")]
36+
public string MandateCode { get; set; } = string.Empty;
37+
}

0 commit comments

Comments
 (0)