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