diff --git a/CHANGELOG.md b/CHANGELOG.md index 626b412..7bec2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ own version is independent of Monnify's API versioning. ## [Unreleased] ### Added +- `IMonnifyCollectionsClient`: refunds - `InitiateRefundAsync`, `GetRefundAsync`, `GetRefundsAsync`. +- `IMonnifyCollectionsClient`: limit profiles - `CreateLimitProfileAsync`, `GetLimitProfilesAsync`, + `UpdateLimitProfileAsync`; and `CreateReservedAccountWithLimitAsync`, + `UpdateReservedAccountLimitAsync` for attaching limit profiles to reserved accounts. +- `IMonnifyCollectionsClient`: sub-accounts - `CreateSubAccountsAsync`, `GetSubAccountsAsync`, + `UpdateSubAccountAsync`, `DeleteSubAccountAsync`. Create takes an array body (not a single object) + and the delete endpoint returns no `responseBody`. Requires relationship-manager approval for live. - `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 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index f3c2ac0..a272a6c 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -27,7 +27,18 @@ Status legend: | `IMonnifyCollectionsClient.SearchTransactionsAsync` | `GET /api/v1/transactions/search?page=&size=&...` | **Implemented** | Many optional filters (reference, amount range, customer, status, date range) | | `IMonnifyCollectionsClient.GetTransactionAsync` | `GET /api/v2/transactions/{transactionReference}` | **Implemented** | Amount fields are quoted strings; `decimal` via `JsonNumberHandling.AllowReadingFromString` | | `IMonnifyCollectionsClient.QueryTransactionAsync` | `GET /api/v2/merchant/transactions/query?transactionReference=&paymentReference=` | **Implemented** | Same response shape as `GetTransactionAsync`; requires at least one query parameter | -| *(Collections — sub-accounts/splitting)* | TBD | Planned | | +| `IMonnifyCollectionsClient.InitiateRefundAsync` | `POST /api/v1/refunds/initiate-refund` | **Implemented** +| `IMonnifyCollectionsClient.GetRefundAsync` | `GET /api/v1/refunds/{refundReference}` | **Implemented** +| `IMonnifyCollectionsClient.GetRefundsAsync` | `GET /api/v1/refunds?page=&size=` | **Implemented** +| `IMonnifyCollectionsClient.CreateLimitProfileAsync` | `POST /api/v1/limit-profile/` | **Implemented** | Sandbox-verified | +| `IMonnifyCollectionsClient.GetLimitProfilesAsync` | `GET /api/v1/limit-profile/` | **Implemented** | `pageable`/`sort` are `null` in the real response — not modelled | +| `IMonnifyCollectionsClient.UpdateLimitProfileAsync` | `PUT /api/v1/limit-profile/{limitProfileCode}` | **Implemented** | Profile code goes in the path; body has only the four editable fields | +| `IMonnifyCollectionsClient.CreateReservedAccountWithLimitAsync` | `POST /api/v1/bank-transfer/reserved-accounts/limit` | **Implemented** | Separate from `CreateReservedAccountAsync` (v2 path); requires `limitProfileCode` | +| `IMonnifyCollectionsClient.UpdateReservedAccountLimitAsync` | `PUT /api/v1/bank-transfer/reserved-accounts/limit` | **Implemented** | Sandbox-verified | +| `IMonnifyCollectionsClient.CreateSubAccountsAsync` | `POST /api/v1/sub-accounts` | **Implemented** | Body and response are both arrays; requires relationship-manager approval for live | +| `IMonnifyCollectionsClient.GetSubAccountsAsync` | `GET /api/v1/sub-accounts` | **Implemented** | Returns flat array (not paged) | +| `IMonnifyCollectionsClient.UpdateSubAccountAsync` | `PUT /api/v1/sub-accounts` | **Implemented** | `subAccountCode` included in the PUT body, not the path | +| `IMonnifyCollectionsClient.DeleteSubAccountAsync` | `DELETE /api/v1/sub-accounts/{subAccountCode}` | **Implemented** | Returns no `responseBody` — handled via `SendVoidAsync` | | `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 | diff --git a/src/Monnify/Collections/IMonnifyCollectionsClient.cs b/src/Monnify/Collections/IMonnifyCollectionsClient.cs index 14a0239..c7f7da9 100644 --- a/src/Monnify/Collections/IMonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/IMonnifyCollectionsClient.cs @@ -78,6 +78,54 @@ Task> SearchTransactionsAsync( Task QueryTransactionAsync( string? transactionReference = null, string? paymentReference = null, CancellationToken cancellationToken = default); + /// + /// Initiates a refund for a completed transaction. Requires prior approval from your + /// relationship manager for the live environment. + /// + Task InitiateRefundAsync(InitiateRefundRequest request, CancellationToken cancellationToken = default); + + /// Returns the current status of a refund by its merchant-supplied refund reference. + Task GetRefundAsync(string refundReference, CancellationToken cancellationToken = default); + + /// Returns all refunds on the integration, newest first. + Task> GetRefundsAsync(int page = 0, int size = 10, CancellationToken cancellationToken = default); + + /// Creates a transaction limit profile that can be attached to reserved accounts. + Task CreateLimitProfileAsync(CreateLimitProfileRequest request, CancellationToken cancellationToken = default); + + /// Returns all limit profiles created on the integration. + Task> GetLimitProfilesAsync(int page = 0, int size = 10, CancellationToken cancellationToken = default); + + /// Updates the name and limit values of an existing limit profile. + Task UpdateLimitProfileAsync(string limitProfileCode, UpdateLimitProfileRequest request, CancellationToken cancellationToken = default); + + /// + /// Creates a reserved account with a transaction limit profile applied from the start. + /// Use when no limit profile is needed. + /// + Task CreateReservedAccountWithLimitAsync( + CreateReservedAccountWithLimitRequest request, CancellationToken cancellationToken = default); + + /// Attaches or replaces the limit profile on an existing reserved account. + Task UpdateReservedAccountLimitAsync( + UpdateReservedAccountLimitRequest request, CancellationToken cancellationToken = default); + + /// + /// Creates one or more sub accounts for splitting payments between different settlement accounts. + /// Requires prior approval from your relationship manager for the live environment. + /// + Task> CreateSubAccountsAsync( + IReadOnlyList requests, CancellationToken cancellationToken = default); + + /// Returns all sub accounts created on the integration. + Task> GetSubAccountsAsync(CancellationToken cancellationToken = default); + + /// Updates the details of an existing sub account. + Task UpdateSubAccountAsync(UpdateSubAccountRequest request, CancellationToken cancellationToken = default); + + /// Deletes a sub account from the integration. + Task DeleteSubAccountAsync(string subAccountCode, 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 diff --git a/src/Monnify/Collections/Models/LimitProfiles/CreateLimitProfileRequest.cs b/src/Monnify/Collections/Models/LimitProfiles/CreateLimitProfileRequest.cs new file mode 100644 index 0000000..6f42ec6 --- /dev/null +++ b/src/Monnify/Collections/Models/LimitProfiles/CreateLimitProfileRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class CreateLimitProfileRequest +{ + [JsonPropertyName("limitProfileName")] + public string LimitProfileName { get; set; } = string.Empty; + + [JsonPropertyName("singleTransactionValue")] + public decimal SingleTransactionValue { get; set; } + + [JsonPropertyName("dailyTransactionValue")] + public decimal DailyTransactionValue { get; set; } + + [JsonPropertyName("dailyTransactionVolume")] + public int DailyTransactionVolume { get; set; } +} diff --git a/src/Monnify/Collections/Models/LimitProfiles/CreateReservedAccountWithLimitRequest.cs b/src/Monnify/Collections/Models/LimitProfiles/CreateReservedAccountWithLimitRequest.cs new file mode 100644 index 0000000..a016aa8 --- /dev/null +++ b/src/Monnify/Collections/Models/LimitProfiles/CreateReservedAccountWithLimitRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class CreateReservedAccountWithLimitRequest +{ + [JsonPropertyName("contractCode")] + public string ContractCode { get; set; } = string.Empty; + + [JsonPropertyName("accountName")] + public string AccountName { get; set; } = string.Empty; + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = "NGN"; + + [JsonPropertyName("accountReference")] + public string AccountReference { get; set; } = string.Empty; + + [JsonPropertyName("customerEmail")] + public string CustomerEmail { get; set; } = string.Empty; + + [JsonPropertyName("customerName")] + public string CustomerName { get; set; } = string.Empty; + + /// When false, must be supplied. + [JsonPropertyName("getAllAvailableBanks")] + public bool GetAllAvailableBanks { get; set; } = true; + + [JsonPropertyName("preferredBanks")] + public IReadOnlyList? PreferredBanks { get; set; } + + /// The limit profile to enforce on this reserved account. + [JsonPropertyName("limitProfileCode")] + public string LimitProfileCode { get; set; } = string.Empty; +} diff --git a/src/Monnify/Collections/Models/LimitProfiles/LimitProfile.cs b/src/Monnify/Collections/Models/LimitProfiles/LimitProfile.cs new file mode 100644 index 0000000..3cc47b2 --- /dev/null +++ b/src/Monnify/Collections/Models/LimitProfiles/LimitProfile.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class LimitProfile +{ + [JsonPropertyName("limitProfileCode")] + public string LimitProfileCode { get; set; } = string.Empty; + + [JsonPropertyName("limitProfileName")] + public string LimitProfileName { get; set; } = string.Empty; + + [JsonPropertyName("singleTransactionValue")] + public decimal SingleTransactionValue { get; set; } + + [JsonPropertyName("dailyTransactionVolume")] + public int DailyTransactionVolume { get; set; } + + [JsonPropertyName("dailyTransactionValue")] + public decimal DailyTransactionValue { get; set; } + + [JsonPropertyName("dateCreated")] + public string DateCreated { get; set; } = string.Empty; + + [JsonPropertyName("lastModified")] + public string LastModified { get; set; } = string.Empty; +} diff --git a/src/Monnify/Collections/Models/LimitProfiles/UpdateLimitProfileRequest.cs b/src/Monnify/Collections/Models/LimitProfiles/UpdateLimitProfileRequest.cs new file mode 100644 index 0000000..b52f62b --- /dev/null +++ b/src/Monnify/Collections/Models/LimitProfiles/UpdateLimitProfileRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class UpdateLimitProfileRequest +{ + [JsonPropertyName("limitProfileName")] + public string LimitProfileName { get; set; } = string.Empty; + + [JsonPropertyName("singleTransactionValue")] + public decimal SingleTransactionValue { get; set; } + + [JsonPropertyName("dailyTransactionValue")] + public decimal DailyTransactionValue { get; set; } + + [JsonPropertyName("dailyTransactionVolume")] + public int DailyTransactionVolume { get; set; } +} diff --git a/src/Monnify/Collections/Models/LimitProfiles/UpdateReservedAccountLimitRequest.cs b/src/Monnify/Collections/Models/LimitProfiles/UpdateReservedAccountLimitRequest.cs new file mode 100644 index 0000000..5ba9fb5 --- /dev/null +++ b/src/Monnify/Collections/Models/LimitProfiles/UpdateReservedAccountLimitRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class UpdateReservedAccountLimitRequest +{ + [JsonPropertyName("accountReference")] + public string AccountReference { get; set; } = string.Empty; + + [JsonPropertyName("limitProfileCode")] + public string LimitProfileCode { get; set; } = string.Empty; +} diff --git a/src/Monnify/Collections/Models/Refunds/InitiateRefundRequest.cs b/src/Monnify/Collections/Models/Refunds/InitiateRefundRequest.cs new file mode 100644 index 0000000..9af43a1 --- /dev/null +++ b/src/Monnify/Collections/Models/Refunds/InitiateRefundRequest.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class InitiateRefundRequest +{ + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("refundAmount")] + public decimal RefundAmount { get; set; } + + [JsonPropertyName("refundReference")] + public string RefundReference { get; set; } = string.Empty; + + [JsonPropertyName("refundReason")] + public string RefundReason { get; set; } = string.Empty; + + [JsonPropertyName("customerNote")] + public string CustomerNote { get; set; } = string.Empty; + + /// Account to credit for the refund. Defaults to the original payment source when omitted. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("destinationAccountNumber")] + public string? DestinationAccountNumber { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("destinationAccountBankCode")] + public string? DestinationAccountBankCode { get; set; } +} diff --git a/src/Monnify/Collections/Models/Refunds/Refund.cs b/src/Monnify/Collections/Models/Refunds/Refund.cs new file mode 100644 index 0000000..93caded --- /dev/null +++ b/src/Monnify/Collections/Models/Refunds/Refund.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class Refund +{ + [JsonPropertyName("refundReference")] + public string RefundReference { get; set; } = string.Empty; + + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("refundReason")] + public string RefundReason { get; set; } = string.Empty; + + [JsonPropertyName("customerNote")] + public string CustomerNote { get; set; } = string.Empty; + + [JsonPropertyName("refundAmount")] + public decimal RefundAmount { get; set; } + + /// E.g. PARTIAL_REFUND or FULL_REFUND. + [JsonPropertyName("refundType")] + public string RefundType { get; set; } = string.Empty; + + /// E.g. PENDING, COMPLETED, FAILED. + [JsonPropertyName("refundStatus")] + public string RefundStatus { get; set; } = string.Empty; + + /// E.g. MERCHANT_WALLET or ORIGINAL_PAYMENT_SOURCE. + [JsonPropertyName("refundStrategy")] + public string RefundStrategy { get; set; } = string.Empty; + + [JsonPropertyName("comment")] + public string? Comment { get; set; } + + [JsonPropertyName("createdOn")] + public string CreatedOn { get; set; } = string.Empty; + + /// Monnify's internal transfer reference (e.g. TRFD|...). Not in our published docs sample but returned by the real API. + [JsonPropertyName("reference")] + public string? Reference { get; set; } + + /// Populated once the refund has settled; empty string when still pending. + [JsonPropertyName("completedOn")] + public string? CompletedOn { get; set; } + + [JsonPropertyName("destinationAccountNumber")] + public string? DestinationAccountNumber { get; set; } + + [JsonPropertyName("destinationAccountName")] + public string? DestinationAccountName { get; set; } + + [JsonPropertyName("destinationAccountBankCode")] + public string? DestinationAccountBankCode { get; set; } + + [JsonPropertyName("destinationBankName")] + public string? DestinationBankName { get; set; } + + [JsonPropertyName("currencyCode")] + public string? CurrencyCode { get; set; } +} diff --git a/src/Monnify/Collections/Models/SubAccounts/CreateSubAccountRequest.cs b/src/Monnify/Collections/Models/SubAccounts/CreateSubAccountRequest.cs new file mode 100644 index 0000000..1af6ec5 --- /dev/null +++ b/src/Monnify/Collections/Models/SubAccounts/CreateSubAccountRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class CreateSubAccountRequest +{ + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = "NGN"; + + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } = string.Empty; + + [JsonPropertyName("bankCode")] + public string BankCode { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("defaultSplitPercentage")] + public decimal DefaultSplitPercentage { get; set; } +} diff --git a/src/Monnify/Collections/Models/SubAccounts/SubAccount.cs b/src/Monnify/Collections/Models/SubAccounts/SubAccount.cs new file mode 100644 index 0000000..ac89274 --- /dev/null +++ b/src/Monnify/Collections/Models/SubAccounts/SubAccount.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class SubAccount +{ + [JsonPropertyName("subAccountCode")] + public string SubAccountCode { get; set; } = string.Empty; + + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } = string.Empty; + + [JsonPropertyName("accountName")] + public string AccountName { get; set; } = string.Empty; + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("bankCode")] + public string BankCode { get; set; } = string.Empty; + + [JsonPropertyName("bankName")] + public string BankName { get; set; } = string.Empty; + + [JsonPropertyName("defaultSplitPercentage")] + public decimal DefaultSplitPercentage { get; set; } + + [JsonPropertyName("settlementProfileCode")] + public string? SettlementProfileCode { get; set; } + + [JsonPropertyName("settlementReportEmails")] + public IReadOnlyList? SettlementReportEmails { get; set; } +} diff --git a/src/Monnify/Collections/Models/SubAccounts/UpdateSubAccountRequest.cs b/src/Monnify/Collections/Models/SubAccounts/UpdateSubAccountRequest.cs new file mode 100644 index 0000000..26f1a9c --- /dev/null +++ b/src/Monnify/Collections/Models/SubAccounts/UpdateSubAccountRequest.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class UpdateSubAccountRequest +{ + [JsonPropertyName("subAccountCode")] + public string SubAccountCode { get; set; } = string.Empty; + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = "NGN"; + + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } = string.Empty; + + [JsonPropertyName("bankCode")] + public string BankCode { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("defaultSplitPercentage")] + public decimal DefaultSplitPercentage { get; set; } +} diff --git a/src/Monnify/Collections/MonnifyCollectionsClient.cs b/src/Monnify/Collections/MonnifyCollectionsClient.cs index 3c83665..2926fd0 100644 --- a/src/Monnify/Collections/MonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/MonnifyCollectionsClient.cs @@ -60,6 +60,99 @@ public Task DeleteReservedAccountAsync(string accountReference, return SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), cancellationToken); } + public Task InitiateRefundAsync(InitiateRefundRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Refunds.Initiate) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task GetRefundAsync(string refundReference, CancellationToken cancellationToken = default) + { + RequireValue(refundReference, nameof(refundReference)); + var path = $"{MonnifyApiPaths.Collections.Refunds.Base}/{Uri.EscapeDataString(refundReference)}"; + return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task> GetRefundsAsync(int page = 0, int size = 10, CancellationToken cancellationToken = default) + { + var path = $"{MonnifyApiPaths.Collections.Refunds.Base}?page={page}&size={size}"; + return SendAsync>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task CreateLimitProfileAsync(CreateLimitProfileRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.LimitProfiles.Base) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task> GetLimitProfilesAsync(int page = 0, int size = 10, CancellationToken cancellationToken = default) + { + var path = $"{MonnifyApiPaths.Collections.LimitProfiles.Base}?page={page}&size={size}"; + return SendAsync>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task UpdateLimitProfileAsync(string limitProfileCode, UpdateLimitProfileRequest request, CancellationToken cancellationToken = default) + { + RequireValue(limitProfileCode, nameof(limitProfileCode)); + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var path = $"{MonnifyApiPaths.Collections.LimitProfiles.Base}{Uri.EscapeDataString(limitProfileCode)}"; + var httpRequest = new HttpRequestMessage(HttpMethod.Put, path) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task CreateReservedAccountWithLimitAsync( + CreateReservedAccountWithLimitRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.LimitProfiles.ReservedAccountLimit) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task UpdateReservedAccountLimitAsync( + UpdateReservedAccountLimitRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Put, MonnifyApiPaths.Collections.LimitProfiles.ReservedAccountLimit) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + public Task CreateInvoiceAsync(CreateInvoiceRequest request, CancellationToken cancellationToken = default) { if (request is null) @@ -195,6 +288,45 @@ public Task QueryTransactionAsync( return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); } + public Task> CreateSubAccountsAsync( + IReadOnlyList requests, CancellationToken cancellationToken = default) + { + if (requests is null) + { + throw new ArgumentNullException(nameof(requests)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.SubAccounts.Base) + { + Content = CreateJsonContent(requests), + }; + return SendAsync>(httpRequest, cancellationToken); + } + + public Task> GetSubAccountsAsync(CancellationToken cancellationToken = default) => + SendAsync>(new HttpRequestMessage(HttpMethod.Get, MonnifyApiPaths.Collections.SubAccounts.Base), cancellationToken); + + public Task UpdateSubAccountAsync(UpdateSubAccountRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Put, MonnifyApiPaths.Collections.SubAccounts.Base) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task DeleteSubAccountAsync(string subAccountCode, CancellationToken cancellationToken = default) + { + RequireValue(subAccountCode, nameof(subAccountCode)); + var path = $"{MonnifyApiPaths.Collections.SubAccounts.Base}/{Uri.EscapeDataString(subAccountCode)}"; + return SendVoidAsync(new HttpRequestMessage(HttpMethod.Delete, path), cancellationToken); + } + public Task CreateMandateAsync(CreateMandateRequest request, CancellationToken cancellationToken = default) { if (request is null) diff --git a/src/Monnify/Http/MonnifyApiPaths.cs b/src/Monnify/Http/MonnifyApiPaths.cs index 8c6427e..032c295 100644 --- a/src/Monnify/Http/MonnifyApiPaths.cs +++ b/src/Monnify/Http/MonnifyApiPaths.cs @@ -58,6 +58,23 @@ internal static class Cards public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize"; } + internal static class SubAccounts + { + public const string Base = "/api/v1/sub-accounts"; + } + + internal static class Refunds + { + public const string Initiate = "/api/v1/refunds/initiate-refund"; + public const string Base = "/api/v1/refunds"; + } + + internal static class LimitProfiles + { + public const string Base = "/api/v1/limit-profile/"; + public const string ReservedAccountLimit = "/api/v1/bank-transfer/reserved-accounts/limit"; + } + internal static class Mandates { public const string Create = "/api/v1/direct-debit/mandate/create"; diff --git a/src/Monnify/Http/MonnifyHttpClientBase.cs b/src/Monnify/Http/MonnifyHttpClientBase.cs index 8e33cec..41c9eea 100644 --- a/src/Monnify/Http/MonnifyHttpClientBase.cs +++ b/src/Monnify/Http/MonnifyHttpClientBase.cs @@ -17,6 +17,34 @@ protected MonnifyHttpClientBase(HttpClient httpClient) _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); } + protected async Task SendVoidAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + var json = await ReadContentAsStringAsync(response, cancellationToken).ConfigureAwait(false); + + MonnifyResponseEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize>(json, MonnifyJsonOptions.Default); + } + catch (JsonException ex) + { + throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex); + } + + if (envelope is null || !envelope.RequestSuccessful) + { + throw new MonnifyApiException( + envelope?.ResponseCode ?? "UNKNOWN", + envelope?.ResponseMessage ?? $"Monnify returned an unsuccessful response (HTTP {(int)response.StatusCode}).", + (int)response.StatusCode, + json); + } + } + protected async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { using var response = await _httpClient diff --git a/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs b/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs index 82ecb1c..3e34c33 100644 --- a/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs +++ b/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs @@ -95,6 +95,125 @@ public async Task InvoiceLifecycle_AgainstRealSandbox_CreateGetListCancel() Assert.Equal("CANCELLED", cancelled.InvoiceStatus); } + [SkippableFact] + public async Task RefundReadOperations_AgainstRealSandbox_ListAndGetByReference() + { + Skip.IfNot(CanRun, "Sandbox credentials/contract code are not set."); + + var client = _fixture.Provider.GetRequiredService(); + + var page = await client.GetRefundsAsync(page: 0, size: 5); + Assert.True(page.TotalElements >= 0); + + if (page.Content.Count > 0) + { + var first = page.Content[0]; + Assert.False(string.IsNullOrWhiteSpace(first.RefundReference)); + + var byRef = await client.GetRefundAsync(first.RefundReference); + Assert.Equal(first.RefundReference, byRef.RefundReference); + Assert.Equal(first.RefundStatus, byRef.RefundStatus); + } + } + + [SkippableFact] + public async Task LimitProfileLifecycle_AgainstRealSandbox_CreateListUpdateAndApplyToReservedAccount() + { + Skip.IfNot(CanRun, "Sandbox credentials/contract code are not set."); + + var client = _fixture.Provider.GetRequiredService(); + var profileName = $"it-profile-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; + + var created = await client.CreateLimitProfileAsync(new CreateLimitProfileRequest + { + LimitProfileName = profileName, + SingleTransactionValue = 5000m, + DailyTransactionValue = 50000m, + DailyTransactionVolume = 10, + }); + Assert.False(string.IsNullOrWhiteSpace(created.LimitProfileCode)); + Assert.Equal(profileName, created.LimitProfileName); + var profileCode = created.LimitProfileCode; + + var page = await client.GetLimitProfilesAsync(page: 0, size: 50); + Assert.Contains(page.Content, p => p.LimitProfileCode == profileCode); + + var updated = await client.UpdateLimitProfileAsync(profileCode, new UpdateLimitProfileRequest + { + LimitProfileName = profileName, + SingleTransactionValue = 10000m, + DailyTransactionValue = 100000m, + DailyTransactionVolume = 20, + }); + Assert.Equal(profileCode, updated.LimitProfileCode); + + var accountReference = $"it-limit-acct-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; + var reservedWithLimit = await client.CreateReservedAccountWithLimitAsync(new CreateReservedAccountWithLimitRequest + { + ContractCode = SandboxContractCode.Value!, + AccountName = "Integration Test Limit Account", + AccountReference = accountReference, + CustomerEmail = "integration-test@example.com", + CustomerName = "Integration Test", + GetAllAvailableBanks = true, + LimitProfileCode = profileCode, + }); + Assert.Equal(accountReference, reservedWithLimit.AccountReference); + + var updatedLimit = await client.UpdateReservedAccountLimitAsync(new UpdateReservedAccountLimitRequest + { + AccountReference = accountReference, + LimitProfileCode = profileCode, + }); + Assert.Equal(accountReference, updatedLimit.AccountReference); + + await client.DeleteReservedAccountAsync(accountReference); + } + + [SkippableFact] + public async Task SubAccountLifecycle_AgainstRealSandbox_CreateGetUpdateDelete() + { + Skip.IfNot(CanRun, "Sandbox credentials/contract code are not set."); + Skip.IfNot(SandboxSubAccountDestination.IsAvailable, + "MONNIFY_SANDBOX_SUBACCOUNT_BANK_CODE / MONNIFY_SANDBOX_SUBACCOUNT_ACCOUNT are not set."); + + var client = _fixture.Provider.GetRequiredService(); + + var created = await client.CreateSubAccountsAsync( + [ + new CreateSubAccountRequest + { + CurrencyCode = "NGN", + AccountNumber = SandboxSubAccountDestination.AccountNumber!, + BankCode = SandboxSubAccountDestination.BankCode!, + Email = "integration-test-subaccount@example.com", + DefaultSplitPercentage = 10m, + }, + ]); + Assert.NotEmpty(created); + var subAccountCode = created[0].SubAccountCode; + Assert.False(string.IsNullOrWhiteSpace(subAccountCode)); + + var all = await client.GetSubAccountsAsync(); + Assert.Contains(all, s => s.SubAccountCode == subAccountCode); + + var updated = await client.UpdateSubAccountAsync(new UpdateSubAccountRequest + { + SubAccountCode = subAccountCode, + CurrencyCode = "NGN", + AccountNumber = SandboxSubAccountDestination.AccountNumber!, + BankCode = SandboxSubAccountDestination.BankCode!, + Email = "integration-test-subaccount-updated@example.com", + DefaultSplitPercentage = 15m, + }); + Assert.Equal(subAccountCode, updated.SubAccountCode); + + await client.DeleteSubAccountAsync(subAccountCode); + + var afterDelete = await client.GetSubAccountsAsync(); + Assert.DoesNotContain(afterDelete, s => s.SubAccountCode == subAccountCode); + } + [SkippableFact] public async Task TransactionLookup_AgainstRealSandbox_InitiateBankTransferSearchGetAndQuery() { diff --git a/tests/Monnify.IntegrationTests/SandboxSubAccountDestination.cs b/tests/Monnify.IntegrationTests/SandboxSubAccountDestination.cs new file mode 100644 index 0000000..51df6d4 --- /dev/null +++ b/tests/Monnify.IntegrationTests/SandboxSubAccountDestination.cs @@ -0,0 +1,15 @@ +namespace Monnify.IntegrationTests; + +/// +/// A real bank account to use as the settlement destination in sub-account integration tests. +/// A made-up account is rejected by Monnify before the sub-account is even created, so this +/// must be a genuine account - not a secret, just specific to whoever is running these tests. +/// +internal static class SandboxSubAccountDestination +{ + public static string? BankCode { get; } = Environment.GetEnvironmentVariable("MONNIFY_SANDBOX_SUBACCOUNT_BANK_CODE"); + + public static string? AccountNumber { get; } = Environment.GetEnvironmentVariable("MONNIFY_SANDBOX_SUBACCOUNT_ACCOUNT"); + + public static bool IsAvailable => !string.IsNullOrWhiteSpace(BankCode) && !string.IsNullOrWhiteSpace(AccountNumber); +} diff --git a/tests/Monnify.Tests/Collections/LimitProfiles/MonnifyCollectionsClientLimitProfileTests.cs b/tests/Monnify.Tests/Collections/LimitProfiles/MonnifyCollectionsClientLimitProfileTests.cs new file mode 100644 index 0000000..6fd5463 --- /dev/null +++ b/tests/Monnify.Tests/Collections/LimitProfiles/MonnifyCollectionsClientLimitProfileTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using Monnify.Collections; +using Monnify.Tests.TestUtilities; + +namespace Monnify.Tests.Collections.LimitProfiles; + +public class MonnifyCollectionsClientLimitProfileTests +{ + private static MonnifyCollectionsClient CreateClient(FakeHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); + + private const string LimitProfileJson = """ + { + "limitProfileCode": "FSYVVWU8UPBD", + "limitProfileName": "Profile0001", + "singleTransactionValue": 2000, + "dailyTransactionVolume": 500, + "dailyTransactionValue": 150000, + "dateCreated": "02/08/2022 02:27:43 AM", + "lastModified": "02/08/2022 02:27:43 AM" + } + """; + + [Fact] + public async Task CreateLimitProfileAsync_SendsPostWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, + $$"""{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", "responseBody": {{LimitProfileJson}} }""")); + var client = CreateClient(handler); + + var result = await client.CreateLimitProfileAsync(new CreateLimitProfileRequest + { + LimitProfileName = "Profile0001", + SingleTransactionValue = 2000m, + DailyTransactionValue = 150000m, + DailyTransactionVolume = 500, + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/limit-profile/", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"limitProfileName\":\"Profile0001\"", handler.RequestBodies[0]); + Assert.Equal("FSYVVWU8UPBD", result.LimitProfileCode); + Assert.Equal(2000m, result.SingleTransactionValue); + Assert.Equal(500, result.DailyTransactionVolume); + } + + [Fact] + public async Task CreateLimitProfileAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.CreateLimitProfileAsync(null!)); + } + + [Fact] + public async Task GetLimitProfilesAsync_SendsGetAndDeserializesPagedResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [ {{LimitProfileJson}} ], + "pageable": null, "last": true, "totalElements": 3, "totalPages": 1, + "sort": null, "first": true, "numberOfElements": 3, "size": 10, "number": 0, "empty": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.GetLimitProfilesAsync(page: 0, size: 10); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/limit-profile/", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("page=0&size=10", handler.Requests[0].RequestUri!.Query.TrimStart('?')); + Assert.Single(result.Content); + Assert.Equal(3, result.TotalElements); + Assert.Equal("FSYVVWU8UPBD", result.Content[0].LimitProfileCode); + } + + [Fact] + public async Task UpdateLimitProfileAsync_SendsPutWithCodeInPathAndBodyWithoutCode() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, + $$"""{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", "responseBody": {{LimitProfileJson}} }""")); + var client = CreateClient(handler); + + var result = await client.UpdateLimitProfileAsync("FSYVVWU8UPBD", new UpdateLimitProfileRequest + { + LimitProfileName = "prof991", + SingleTransactionValue = 70000m, + DailyTransactionValue = 100000000m, + DailyTransactionVolume = 4000, + }); + + Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); + Assert.Equal("/api/v1/limit-profile/FSYVVWU8UPBD", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"limitProfileName\":\"prof991\"", handler.RequestBodies[0]); + Assert.DoesNotContain("limitProfileCode", handler.RequestBodies[0]); + Assert.Equal("FSYVVWU8UPBD", result.LimitProfileCode); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task UpdateLimitProfileAsync_MissingCode_Throws(string? code) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync( + () => client.UpdateLimitProfileAsync(code!, new UpdateLimitProfileRequest())); + } + + [Fact] + public async Task UpdateLimitProfileAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync( + () => client.UpdateLimitProfileAsync("FSYVVWU8UPBD", null!)); + } + + [Fact] + public async Task CreateReservedAccountWithLimitAsync_SendsPostToLimitEndpointWithLimitProfileCode() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "contractCode": "7059707855", + "accountReference": "ref-00--7", + "accountName": "Kan", + "currencyCode": "NGN", + "customerEmail": "KanYo@monnify.com", + "customerName": "Kan Yo", + "accountNumber": "5000578928", + "bankName": "Moniepoint Microfinance bank", + "bankCode": "50515", + "collectionChannel": "RESERVED_ACCOUNT", + "reservationReference": "0B70FP4CNC61U334XFG1", + "reservedAccountType": "GENERAL", + "status": "ACTIVE", + "createdOn": "2022-08-02T02:53:25.617Z", + "incomeSplitConfig": [], + "restrictPaymentSource": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.CreateReservedAccountWithLimitAsync(new CreateReservedAccountWithLimitRequest + { + ContractCode = "7059707855", + AccountName = "Kan Yo' Reserved with Limit", + CurrencyCode = "NGN", + AccountReference = "ref-00--7", + CustomerEmail = "KanYo@monnify.com", + CustomerName = "Kan Yo", + GetAllAvailableBanks = false, + PreferredBanks = ["50515"], + LimitProfileCode = "FSYVVWU8UPBD", + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/bank-transfer/reserved-accounts/limit", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"limitProfileCode\":\"FSYVVWU8UPBD\"", handler.RequestBodies[0]); + Assert.Equal("ref-00--7", result.AccountReference); + Assert.Equal("ACTIVE", result.Status); + } + + [Fact] + public async Task CreateReservedAccountWithLimitAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.CreateReservedAccountWithLimitAsync(null!)); + } + + [Fact] + public async Task UpdateReservedAccountLimitAsync_SendsPutWithReferenceAndCode() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "contractCode": "7059707855", + "accountReference": "ref-00--7", + "accountName": "Kan", + "currencyCode": "NGN", + "customerEmail": "KanYo@monnify.com", + "customerName": "Kan Yo", + "accountNumber": "5000578928", + "bankName": "Moniepoint Microfinance bank", + "bankCode": "50515", + "collectionChannel": "RESERVED_ACCOUNT", + "reservationReference": "0B70FP4CNC61U334XFG1", + "reservedAccountType": "GENERAL", + "status": "ACTIVE", + "createdOn": "2022-08-02T02:53:25.617Z", + "incomeSplitConfig": [], + "restrictPaymentSource": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.UpdateReservedAccountLimitAsync(new UpdateReservedAccountLimitRequest + { + AccountReference = "ref-00--7", + LimitProfileCode = "FSYVVWU8UPBD", + }); + + Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); + Assert.Equal("/api/v1/bank-transfer/reserved-accounts/limit", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"accountReference\":\"ref-00--7\"", handler.RequestBodies[0]); + Assert.Contains("\"limitProfileCode\":\"FSYVVWU8UPBD\"", handler.RequestBodies[0]); + Assert.Equal("ref-00--7", result.AccountReference); + } + + [Fact] + public async Task UpdateReservedAccountLimitAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.UpdateReservedAccountLimitAsync(null!)); + } +} diff --git a/tests/Monnify.Tests/Collections/Refunds/MonnifyCollectionsClientRefundTests.cs b/tests/Monnify.Tests/Collections/Refunds/MonnifyCollectionsClientRefundTests.cs new file mode 100644 index 0000000..a0b8d75 --- /dev/null +++ b/tests/Monnify.Tests/Collections/Refunds/MonnifyCollectionsClientRefundTests.cs @@ -0,0 +1,133 @@ +using System.Net; +using Monnify.Collections; +using Monnify.Tests.TestUtilities; + +namespace Monnify.Tests.Collections.Refunds; + +public class MonnifyCollectionsClientRefundTests +{ + private static MonnifyCollectionsClient CreateClient(FakeHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); + + private const string RefundJson = """ + { + "refundReference": "202100op3456", + "transactionReference": "MNFY|65|20220727094724|000477", + "refundReason": "Order cancelled!", + "customerNote": "An optional note", + "refundAmount": 100, + "refundType": "PARTIAL_REFUND", + "refundStatus": "COMPLETED", + "refundStrategy": "MERCHANT_WALLET", + "comment": "Transaction refund is in progress.", + "createdOn": "02/08/2022 03:35:22 AM" + } + """; + + [Fact] + public async Task InitiateRefundAsync_SendsPostWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, + $$"""{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", "responseBody": {{RefundJson}} }""")); + var client = CreateClient(handler); + + var result = await client.InitiateRefundAsync(new InitiateRefundRequest + { + TransactionReference = "MNFY|65|20220727094724|000477", + RefundAmount = 100m, + RefundReference = "202100op3456", + RefundReason = "Order cancelled!", + CustomerNote = "An optional note", + DestinationAccountNumber = "3270005594", + DestinationAccountBankCode = "050", + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/refunds/initiate-refund", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"refundReference\":\"202100op3456\"", handler.RequestBodies[0]); + Assert.Contains("\"destinationAccountNumber\":\"3270005594\"", handler.RequestBodies[0]); + Assert.Equal("202100op3456", result.RefundReference); + Assert.Equal("PARTIAL_REFUND", result.RefundType); + Assert.Equal("COMPLETED", result.RefundStatus); + Assert.Equal("MERCHANT_WALLET", result.RefundStrategy); + } + + [Fact] + public async Task InitiateRefundAsync_WithoutOptionalDestination_OmitsFieldsFromBody() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, + $$"""{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", "responseBody": {{RefundJson}} }""")); + var client = CreateClient(handler); + + await client.InitiateRefundAsync(new InitiateRefundRequest + { + TransactionReference = "MNFY|65|20220727094724|000477", + RefundAmount = 100m, + RefundReference = "202100op3456", + RefundReason = "Order cancelled!", + CustomerNote = "An optional note", + }); + + Assert.DoesNotContain("destinationAccountNumber", handler.RequestBodies[0]); + Assert.DoesNotContain("destinationAccountBankCode", handler.RequestBodies[0]); + } + + [Fact] + public async Task InitiateRefundAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.InitiateRefundAsync(null!)); + } + + [Fact] + public async Task GetRefundAsync_SendsGetWithReferenceInPath_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, + $$"""{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", "responseBody": {{RefundJson}} }""")); + var client = CreateClient(handler); + + var result = await client.GetRefundAsync("202100op3456"); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/refunds/202100op3456", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("202100op3456", result.RefundReference); + Assert.Equal(100m, result.RefundAmount); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetRefundAsync_MissingReference_Throws(string? refundReference) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.GetRefundAsync(refundReference!)); + } + + [Fact] + public async Task GetRefundsAsync_SendsGetWithPagingParams_AndDeserializesPagedResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [ {{RefundJson}} ], + "pageable": null, "last": false, "totalElements": 17, "totalPages": 2, + "sort": null, "first": true, "numberOfElements": 10, "size": 10, "number": 0, "empty": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.GetRefundsAsync(page: 0, size: 10); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/refunds", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("page=0&size=10", handler.Requests[0].RequestUri!.Query.TrimStart('?')); + Assert.Single(result.Content); + Assert.Equal(17, result.TotalElements); + Assert.Equal(2, result.TotalPages); + Assert.Equal("COMPLETED", result.Content[0].RefundStatus); + } +} diff --git a/tests/Monnify.Tests/Collections/SubAccounts/MonnifyCollectionsClientSubAccountTests.cs b/tests/Monnify.Tests/Collections/SubAccounts/MonnifyCollectionsClientSubAccountTests.cs new file mode 100644 index 0000000..fa8018f --- /dev/null +++ b/tests/Monnify.Tests/Collections/SubAccounts/MonnifyCollectionsClientSubAccountTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using Monnify.Collections; +using Monnify.Tests.TestUtilities; + +namespace Monnify.Tests.Collections.SubAccounts; + +public class MonnifyCollectionsClientSubAccountTests +{ + private static MonnifyCollectionsClient CreateClient(FakeHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); + + [Fact] + public async Task CreateSubAccountsAsync_SendsPostWithArrayBody_AndDeserializesArray() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": [ + { + "subAccountCode": "MFY_SUB_811397375865", + "accountNumber": "0211319282", + "accountName": "ALEMOH DANIEL MOSES", + "currencyCode": "NGN", + "email": "tamira1@gmail.com", + "bankCode": "058", + "bankName": "GTBank", + "defaultSplitPercentage": 20.87, + "settlementProfileCode": "8717495899", + "settlementReportEmails": [] + } + ] } + """)); + var client = CreateClient(handler); + + var result = await client.CreateSubAccountsAsync(new[] + { + new CreateSubAccountRequest + { + CurrencyCode = "NGN", + AccountNumber = "0211319282", + BankCode = "058", + Email = "tamira1@gmail.com", + DefaultSplitPercentage = 20.87m, + }, + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/sub-accounts", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"accountNumber\":\"0211319282\"", handler.RequestBodies[0]); + Assert.Single(result); + Assert.Equal("MFY_SUB_811397375865", result[0].SubAccountCode); + Assert.Equal("ALEMOH DANIEL MOSES", result[0].AccountName); + Assert.Equal(20.87m, result[0].DefaultSplitPercentage); + Assert.Equal("8717495899", result[0].SettlementProfileCode); + } + + [Fact] + public async Task CreateSubAccountsAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.CreateSubAccountsAsync(null!)); + } + + [Fact] + public async Task GetSubAccountsAsync_SendsGetAndDeserializesArray() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": [ + { + "subAccountCode": "MFY_SUB_811397375865", + "accountNumber": "0211319282", + "accountName": "ALEMOH DANIEL MOSES", + "currencyCode": "NGN", + "email": "tamira1@gmail.com", + "bankCode": "058", + "bankName": "GTBank", + "defaultSplitPercentage": 20.87 + } + ] } + """)); + var client = CreateClient(handler); + + var result = await client.GetSubAccountsAsync(); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/sub-accounts", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Single(result); + Assert.Equal("MFY_SUB_811397375865", result[0].SubAccountCode); + Assert.Null(result[0].SettlementProfileCode); + } + + [Fact] + public async Task UpdateSubAccountAsync_SendsPutWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "subAccountCode": "MFY_SUB_811397375865", + "accountNumber": "0211319282", + "accountName": "ALEMOH DANIEL MOSES", + "currencyCode": "NGN", + "email": "kali@gmail.com", + "bankCode": "058", + "bankName": "GTBank", + "defaultSplitPercentage": 25, + "settlementProfileCode": "8717495899", + "settlementReportEmails": [] + } } + """)); + var client = CreateClient(handler); + + var result = await client.UpdateSubAccountAsync(new UpdateSubAccountRequest + { + SubAccountCode = "MFY_SUB_811397375865", + CurrencyCode = "NGN", + AccountNumber = "0211319282", + BankCode = "058", + Email = "kali@gmail.com", + DefaultSplitPercentage = 25m, + }); + + Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); + Assert.Equal("/api/v1/sub-accounts", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"subAccountCode\":\"MFY_SUB_811397375865\"", handler.RequestBodies[0]); + Assert.Equal("kali@gmail.com", result.Email); + Assert.Equal(25m, result.DefaultSplitPercentage); + } + + [Fact] + public async Task UpdateSubAccountAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.UpdateSubAccountAsync(null!)); + } + + [Fact] + public async Task DeleteSubAccountAsync_SendsDeleteWithCodeInPath_AndSucceedsWithNoResponseBody() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0" } + """)); + var client = CreateClient(handler); + + await client.DeleteSubAccountAsync("MFY_SUB_811397375865"); + + Assert.Equal(HttpMethod.Delete, handler.Requests[0].Method); + Assert.Equal("/api/v1/sub-accounts/MFY_SUB_811397375865", handler.Requests[0].RequestUri!.AbsolutePath); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task DeleteSubAccountAsync_MissingSubAccountCode_Throws(string? subAccountCode) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.DeleteSubAccountAsync(subAccountCode!)); + } +}