diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a33250..254b1cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,13 @@ jobs: - name: Restore run: dotnet restore + # Run NBGV's own cloud step before build so it sets GitHub Actions environment + # variables using the native file-command format. Without this, NBGV's MSBuild + # task writes to $GITHUB_ENV using a format that newer runners reject. + - name: Set version + uses: dotnet/nbgv@master + id: nbgv + - name: Verify formatting run: dotnet format --verify-no-changes --no-restore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f58b036..2d3765d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,10 @@ jobs: - name: Restore run: dotnet restore + - name: Set version + uses: dotnet/nbgv@master + id: nbgv + - name: Build run: dotnet build --configuration Release --no-restore diff --git a/CHANGELOG.md b/CHANGELOG.md index 711d79a..534d7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Each entry that introduces or changes an API call should cross-reference the relevant row in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md), since the SDK's own version is independent of Monnify's API versioning. +## [Unreleased] + +### Features + +* add paycode support — `CreatePaycodeAsync`, `GetPaycodesAsync`, `GetPaycodeAsync`, `CancelPaycodeAsync`, `GetUnmaskedPaycodeAsync` (`IMonnifyCollectionsClient`) +* add customer wallet support — `CreateWalletAsync`, `GetWalletsAsync`, `GetCustomerWalletBalanceAsync`, `GetWalletTransactionsAsync` (`IMonnifyDisbursementsClient`) + +### Fixed + +* envelope handling now tolerates endpoints (e.g. paycodes) that omit `requestSuccessful` from their response + ## [0.5.0](https://github.com/Monnify/monnify-dotnet-lib/compare/v0.4.0...v0.5.0) (2026-06-30) @@ -69,24 +80,6 @@ own version is independent of Monnify's API versioning. * add card transactions and automated release tagging ([25a62b3](https://github.com/Monnify/monnify-dotnet-lib/commit/25a62b30c36c7468faad960f48e5872eb9264d8d)) * add card transactions client (charge, OTP, 3DS authorize) ([73cc17e](https://github.com/Monnify/monnify-dotnet-lib/commit/73cc17ea37382099bbefa50423ed48856ac9e8dc)) -## [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 - 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 ### Fixed diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index a272a6c..768812c 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -45,6 +45,11 @@ Status legend: | `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 | +| `IMonnifyCollectionsClient.CreatePaycodeAsync` | `POST /api/v1/paycode` | **Implemented** | Requires paycode feature activation; `clientId` in the body is the merchant's API key. Sandbox returns "Unable to process request" if not activated | +| `IMonnifyCollectionsClient.GetPaycodesAsync` | `GET /api/v1/paycode?transactionReference=&beneficiaryName=&transactionStatus=&from=&to=` | **Implemented** | `from`/`to` are unix timestamps; `paycode` field is masked (e.g. `******46`) in list and single-get responses | +| `IMonnifyCollectionsClient.GetPaycodeAsync` | `GET /api/v1/paycode/{paycodeReference}` | **Implemented** | Returns masked paycode; response omits `requestSuccessful` field (handled via nullable envelope) | +| `IMonnifyCollectionsClient.CancelPaycodeAsync` | `DELETE /api/v1/paycode/{paycodeReference}` | **Implemented** | Returns the cancelled paycode object; sandbox discovered undocumented `cancelDate` field | +| `IMonnifyCollectionsClient.GetUnmaskedPaycodeAsync` | `GET /api/v1/paycode/{paycodeReference}/authorize` | **Implemented** | Returns the unmasked paycode value (e.g. `04797046` instead of `******46`) | | *(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 | @@ -60,6 +65,10 @@ Status legend: | `IMonnifyDisbursementsClient.GetBulkTransfersAsync` | `GET /api/v2/disbursements/bulk?sourceAccountNumber=&pageNo=&pageSize=` | **Implemented** | Docs show a `/transactions` suffix on this path; that 404s — omit it | | `IMonnifyDisbursementsClient.SearchTransactionsAsync` | `GET /api/v2/disbursements/search-transactions?sourceAccountNumber=&...` | **Implemented** | | | `IMonnifyDisbursementsClient.GetWalletBalanceAsync` | `GET /api/v2/disbursements/wallet-balance?accountNumber=` | **Implemented** | Accepts both numeric and quoted-string balances via `AllowReadingFromString` | +| `IMonnifyDisbursementsClient.CreateWalletAsync` | `POST /api/v1/disbursements/wallet` | **Implemented** | Requires wallet feature activation; `bvnDetails` is a nested object | +| `IMonnifyDisbursementsClient.GetWalletsAsync` | `GET /api/v1/disbursements/wallet?pageNo=&pageSize=&walletReference=` | **Implemented** | 21 wallets in sandbox; `createdOn` is absent in list items but present on create response | +| `IMonnifyDisbursementsClient.GetCustomerWalletBalanceAsync` | `GET /api/v1/disbursements/wallet/balance?accountNumber=` | **Implemented** +| `IMonnifyDisbursementsClient.GetWalletTransactionsAsync` | `GET /api/v1/disbursements/wallet/transactions?accountNumber=&pageNo=&pageSize=` | **Implemented** | Balance amounts sent as large integers (e.g. 5000000000); `AllowReadingFromString` applied defensively | | `IMonnifyVerificationClient.ValidateAccountNumberAsync` | `GET /api/v1/disbursements/account/validate?accountNumber=&bankCode=` | **Implemented** | Free on both sandbox and live | | `IMonnifyVerificationClient.MatchBvnDetailsAsync` | `POST /api/v1/vas/bvn-details-match` | **Implemented** | Bills the merchant wallet per request | | `IMonnifyVerificationClient.MatchBvnToAccountAsync` | `POST /api/v1/vas/bvn-account-match` | **Implemented** | Bills the merchant wallet per request | diff --git a/src/Monnify/Authentication/MonnifyTokenProvider.cs b/src/Monnify/Authentication/MonnifyTokenProvider.cs index bd5dc05..2161e70 100644 --- a/src/Monnify/Authentication/MonnifyTokenProvider.cs +++ b/src/Monnify/Authentication/MonnifyTokenProvider.cs @@ -94,7 +94,7 @@ private async Task LoginAsync(CancellationToken cancellation } var responseBody = envelope?.ResponseBody; - if (envelope is null || !envelope.RequestSuccessful || string.IsNullOrEmpty(responseBody?.AccessToken)) + if (envelope is null || envelope.RequestSuccessful == false || string.IsNullOrEmpty(responseBody?.AccessToken)) { throw new MonnifyAuthenticationException( $"Monnify rejected the authentication request: {envelope?.ResponseMessage ?? "unknown error"}."); diff --git a/src/Monnify/Collections/IMonnifyCollectionsClient.cs b/src/Monnify/Collections/IMonnifyCollectionsClient.cs index c7f7da9..3bc9b43 100644 --- a/src/Monnify/Collections/IMonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/IMonnifyCollectionsClient.cs @@ -161,4 +161,23 @@ Task> CreateSubAccountsAsync( /// Task ListMandatesAsync( ListMandatesFilter filter, int page = 0, int limit = 20, CancellationToken cancellationToken = default); + + /// Creates a paycode that a beneficiary can use to collect cash at an agent point. + Task CreatePaycodeAsync(CreatePaycodeRequest request, CancellationToken cancellationToken = default); + + /// + /// Returns a paged history of generated paycodes, optionally filtered by transaction + /// reference, beneficiary name, status, or a date range (unix timestamps). + /// + Task> GetPaycodesAsync( + PaycodeSearchFilter? filter = null, CancellationToken cancellationToken = default); + + /// Returns the details of a specific paycode by its merchant reference. + Task GetPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default); + + /// Cancels an active paycode so it can no longer be used for cash collection. + Task CancelPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default); + + /// Returns the unmasked (clear) paycode value for a given paycode reference. + Task GetUnmaskedPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default); } diff --git a/src/Monnify/Collections/Models/Paycodes/CreatePaycodeRequest.cs b/src/Monnify/Collections/Models/Paycodes/CreatePaycodeRequest.cs new file mode 100644 index 0000000..5702198 --- /dev/null +++ b/src/Monnify/Collections/Models/Paycodes/CreatePaycodeRequest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class CreatePaycodeRequest +{ + [JsonPropertyName("beneficiaryName")] + public string BeneficiaryName { get; set; } = string.Empty; + + [JsonPropertyName("amount")] + public decimal Amount { get; set; } + + [JsonPropertyName("paycodeReference")] + public string PaycodeReference { get; set; } = string.Empty; + + /// Format: YYYY-MM-DD HH:MM:SS. + [JsonPropertyName("expiryDate")] + public string ExpiryDate { get; set; } = string.Empty; + + /// The merchant's API key (your MonnifyClientOptions.ApiKey). + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = string.Empty; +} diff --git a/src/Monnify/Collections/Models/Paycodes/Paycode.cs b/src/Monnify/Collections/Models/Paycodes/Paycode.cs new file mode 100644 index 0000000..34b0103 --- /dev/null +++ b/src/Monnify/Collections/Models/Paycodes/Paycode.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +public sealed class Paycode +{ + /// The generated numeric code the beneficiary uses to collect cash. + [JsonPropertyName("paycode")] + public string PaycodeValue { get; set; } = string.Empty; + + [JsonPropertyName("transactionReference")] + public string TransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("paycodeReference")] + public string PaycodeReference { get; set; } = string.Empty; + + [JsonPropertyName("beneficiaryName")] + public string BeneficiaryName { get; set; } = string.Empty; + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("amount")] + public decimal Amount { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("fee")] + public decimal Fee { get; set; } + + /// E.g. PENDING, COMPLETED, CANCELLED. + [JsonPropertyName("transactionStatus")] + public string TransactionStatus { get; set; } = string.Empty; + + /// Format: YYYY-MM-DD HH:MM:SS. + [JsonPropertyName("expiryDate")] + public string ExpiryDate { get; set; } = string.Empty; + + [JsonPropertyName("createdOn")] + public string CreatedOn { get; set; } = string.Empty; + + [JsonPropertyName("createdBy")] + public string CreatedBy { get; set; } = string.Empty; + + [JsonPropertyName("modifiedBy")] + public string ModifiedBy { get; set; } = string.Empty; + + /// Populated once the paycode has been cancelled; absent otherwise. + [JsonPropertyName("cancelDate")] + public string? CancelDate { get; set; } +} diff --git a/src/Monnify/Collections/Models/Paycodes/PaycodeSearchFilter.cs b/src/Monnify/Collections/Models/Paycodes/PaycodeSearchFilter.cs new file mode 100644 index 0000000..6f8e03f --- /dev/null +++ b/src/Monnify/Collections/Models/Paycodes/PaycodeSearchFilter.cs @@ -0,0 +1,14 @@ +namespace Monnify.Collections; + +public sealed class PaycodeSearchFilter +{ + public string? TransactionReference { get; set; } + public string? BeneficiaryName { get; set; } + public string? TransactionStatus { get; set; } + + /// Unix timestamp (seconds) for the start of the date range. + public long? From { get; set; } + + /// Unix timestamp (seconds) for the end of the date range. + public long? To { get; set; } +} diff --git a/src/Monnify/Collections/MonnifyCollectionsClient.cs b/src/Monnify/Collections/MonnifyCollectionsClient.cs index 2926fd0..ebe38e2 100644 --- a/src/Monnify/Collections/MonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/MonnifyCollectionsClient.cs @@ -403,6 +403,60 @@ public Task ListMandatesAsync( return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); } + public Task CreatePaycodeAsync(CreatePaycodeRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Paycodes.Base) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task> GetPaycodesAsync( + PaycodeSearchFilter? filter = null, CancellationToken cancellationToken = default) + { + var query = new List(); + if (filter is not null) + { + AppendIfPresent(query, "transactionReference", filter.TransactionReference); + AppendIfPresent(query, "beneficiaryName", filter.BeneficiaryName); + AppendIfPresent(query, "transactionStatus", filter.TransactionStatus); + if (filter.From.HasValue) { query.Add($"from={filter.From.Value}"); } + if (filter.To.HasValue) { query.Add($"to={filter.To.Value}"); } + } + + var path = query.Count > 0 + ? $"{MonnifyApiPaths.Collections.Paycodes.Base}?{string.Join("&", query)}" + : MonnifyApiPaths.Collections.Paycodes.Base; + return SendAsync>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task GetPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default) + { + RequireValue(paycodeReference, nameof(paycodeReference)); + var path = $"{MonnifyApiPaths.Collections.Paycodes.Base}/{Uri.EscapeDataString(paycodeReference)}"; + return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task CancelPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default) + { + RequireValue(paycodeReference, nameof(paycodeReference)); + var path = $"{MonnifyApiPaths.Collections.Paycodes.Base}/{Uri.EscapeDataString(paycodeReference)}"; + return SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), cancellationToken); + } + + public Task GetUnmaskedPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default) + { + RequireValue(paycodeReference, nameof(paycodeReference)); + var path = $"{MonnifyApiPaths.Collections.Paycodes.Base}/{Uri.EscapeDataString(paycodeReference)}/authorize"; + 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/Disbursements/IMonnifyDisbursementsClient.cs b/src/Monnify/Disbursements/IMonnifyDisbursementsClient.cs index 7dfcbf7..035d1ab 100644 --- a/src/Monnify/Disbursements/IMonnifyDisbursementsClient.cs +++ b/src/Monnify/Disbursements/IMonnifyDisbursementsClient.cs @@ -66,4 +66,24 @@ Task> SearchTransactionsAsync( int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default); Task GetWalletBalanceAsync(string accountNumber, CancellationToken cancellationToken = default); + + /// + /// Creates a wallet for a customer. Requires the wallet feature to be enabled for the + /// merchant — contact sales@monnify.com to request access. + /// + Task CreateWalletAsync(CreateWalletRequest request, CancellationToken cancellationToken = default); + + /// + /// Returns all customer wallets on the integration, optionally filtered by + /// to retrieve a specific wallet. + /// + Task> GetWalletsAsync( + string? walletReference = null, int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default); + + /// Returns the available and ledger balance for a specific customer wallet. + Task GetCustomerWalletBalanceAsync(string accountNumber, CancellationToken cancellationToken = default); + + /// Returns transactions for a customer wallet, identified by its account number. + Task> GetWalletTransactionsAsync( + string accountNumber, int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default); } diff --git a/src/Monnify/Disbursements/Models/Wallets/BvnDetails.cs b/src/Monnify/Disbursements/Models/Wallets/BvnDetails.cs new file mode 100644 index 0000000..73e28b0 --- /dev/null +++ b/src/Monnify/Disbursements/Models/Wallets/BvnDetails.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Disbursements; + +public sealed class BvnDetails +{ + [JsonPropertyName("bvn")] + public string Bvn { get; set; } = string.Empty; + + /// Format: yyyy-MM-dd. + [JsonPropertyName("bvnDateOfBirth")] + public string BvnDateOfBirth { get; set; } = string.Empty; +} diff --git a/src/Monnify/Disbursements/Models/Wallets/CreateWalletRequest.cs b/src/Monnify/Disbursements/Models/Wallets/CreateWalletRequest.cs new file mode 100644 index 0000000..9c163a0 --- /dev/null +++ b/src/Monnify/Disbursements/Models/Wallets/CreateWalletRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Disbursements; + +public sealed class CreateWalletRequest +{ + [JsonPropertyName("walletReference")] + public string WalletReference { get; set; } = string.Empty; + + [JsonPropertyName("walletName")] + public string WalletName { get; set; } = string.Empty; + + [JsonPropertyName("customerName")] + public string CustomerName { get; set; } = string.Empty; + + [JsonPropertyName("customerEmail")] + public string CustomerEmail { get; set; } = string.Empty; + + [JsonPropertyName("bvnDetails")] + public BvnDetails BvnDetails { get; set; } = new(); +} diff --git a/src/Monnify/Disbursements/Models/Wallets/CustomerWallet.cs b/src/Monnify/Disbursements/Models/Wallets/CustomerWallet.cs new file mode 100644 index 0000000..ea9f28a --- /dev/null +++ b/src/Monnify/Disbursements/Models/Wallets/CustomerWallet.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Disbursements; + +public sealed class CustomerWallet +{ + [JsonPropertyName("walletReference")] + public string WalletReference { get; set; } = string.Empty; + + [JsonPropertyName("walletName")] + public string WalletName { get; set; } = string.Empty; + + [JsonPropertyName("customerName")] + public string CustomerName { get; set; } = string.Empty; + + [JsonPropertyName("customerEmail")] + public string CustomerEmail { get; set; } = string.Empty; + + /// E.g. SELF — who bears wallet fees. + [JsonPropertyName("feeBearer")] + public string FeeBearer { get; set; } = string.Empty; + + [JsonPropertyName("bvnDetails")] + public BvnDetails? BvnDetails { get; set; } + + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } = string.Empty; + + [JsonPropertyName("accountName")] + public string AccountName { get; set; } = string.Empty; + + [JsonPropertyName("topUpAccountDetails")] + public WalletTopUpAccountDetails? TopUpAccountDetails { get; set; } + + /// Only present in the list response, not in the create response. + [JsonPropertyName("createdOn")] + public string? CreatedOn { get; set; } +} diff --git a/src/Monnify/Disbursements/Models/Wallets/WalletTopUpAccountDetails.cs b/src/Monnify/Disbursements/Models/Wallets/WalletTopUpAccountDetails.cs new file mode 100644 index 0000000..ad7a205 --- /dev/null +++ b/src/Monnify/Disbursements/Models/Wallets/WalletTopUpAccountDetails.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Disbursements; + +public sealed class WalletTopUpAccountDetails +{ + [JsonPropertyName("accountNumber")] + public string AccountNumber { get; set; } = string.Empty; + + [JsonPropertyName("accountName")] + public string AccountName { get; set; } = string.Empty; + + [JsonPropertyName("bankCode")] + public string BankCode { get; set; } = string.Empty; + + [JsonPropertyName("bankName")] + public string BankName { get; set; } = string.Empty; + + [JsonPropertyName("createdOn")] + public string? CreatedOn { get; set; } +} diff --git a/src/Monnify/Disbursements/Models/Wallets/WalletTransaction.cs b/src/Monnify/Disbursements/Models/Wallets/WalletTransaction.cs new file mode 100644 index 0000000..e82e86c --- /dev/null +++ b/src/Monnify/Disbursements/Models/Wallets/WalletTransaction.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Disbursements; + +public sealed class WalletTransaction +{ + [JsonPropertyName("walletTransactionReference")] + public string WalletTransactionReference { get; set; } = string.Empty; + + [JsonPropertyName("monnifyTransactionReference")] + public string MonnifyTransactionReference { get; set; } = string.Empty; + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("availableBalanceBefore")] + public decimal AvailableBalanceBefore { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("availableBalanceAfter")] + public decimal AvailableBalanceAfter { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonPropertyName("amount")] + public decimal Amount { get; set; } + + [JsonPropertyName("transactionDate")] + public string TransactionDate { get; set; } = string.Empty; + + /// E.g. DEBIT or CREDIT. + [JsonPropertyName("transactionType")] + public string TransactionType { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("narration")] + public string? Narration { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; +} diff --git a/src/Monnify/Disbursements/MonnifyDisbursementsClient.cs b/src/Monnify/Disbursements/MonnifyDisbursementsClient.cs index a3656ae..4754523 100644 --- a/src/Monnify/Disbursements/MonnifyDisbursementsClient.cs +++ b/src/Monnify/Disbursements/MonnifyDisbursementsClient.cs @@ -153,6 +153,45 @@ public Task GetWalletBalanceAsync(string accountNumber, Cancellat return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); } + public Task CreateWalletAsync(CreateWalletRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Disbursements.CustomerWallets.Base) + { + Content = CreateJsonContent(request), + }; + return SendAsync(httpRequest, cancellationToken); + } + + public Task> GetWalletsAsync( + string? walletReference = null, int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default) + { + var query = new List { $"pageNo={pageNo}", $"pageSize={pageSize}" }; + AppendIfPresent(query, "walletReference", walletReference); + var path = $"{MonnifyApiPaths.Disbursements.CustomerWallets.Base}?{string.Join("&", query)}"; + return SendAsync>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task GetCustomerWalletBalanceAsync(string accountNumber, CancellationToken cancellationToken = default) + { + RequireValue(accountNumber, nameof(accountNumber)); + var path = $"{MonnifyApiPaths.Disbursements.CustomerWallets.Balance}?accountNumber={Uri.EscapeDataString(accountNumber)}"; + return SendAsync(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken); + } + + public Task> GetWalletTransactionsAsync( + string accountNumber, int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default) + { + RequireValue(accountNumber, nameof(accountNumber)); + var path = $"{MonnifyApiPaths.Disbursements.CustomerWallets.Transactions}" + + $"?accountNumber={Uri.EscapeDataString(accountNumber)}&pageNo={pageNo}&pageSize={pageSize}"; + 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/MonnifyApiPaths.cs b/src/Monnify/Http/MonnifyApiPaths.cs index 032c295..65ad029 100644 --- a/src/Monnify/Http/MonnifyApiPaths.cs +++ b/src/Monnify/Http/MonnifyApiPaths.cs @@ -75,6 +75,11 @@ internal static class LimitProfiles public const string ReservedAccountLimit = "/api/v1/bank-transfer/reserved-accounts/limit"; } + internal static class Paycodes + { + public const string Base = "/api/v1/paycode"; + } + internal static class Mandates { public const string Create = "/api/v1/direct-debit/mandate/create"; @@ -110,6 +115,13 @@ internal static class Bulk public const string SearchTransactions = "/api/v2/disbursements/search-transactions"; public const string WalletBalance = "/api/v2/disbursements/wallet-balance"; + + internal static class CustomerWallets + { + public const string Base = "/api/v1/disbursements/wallet"; + public const string Balance = "/api/v1/disbursements/wallet/balance"; + public const string Transactions = "/api/v1/disbursements/wallet/transactions"; + } } internal static class Bills diff --git a/src/Monnify/Http/MonnifyHttpClientBase.cs b/src/Monnify/Http/MonnifyHttpClientBase.cs index 41c9eea..fe5e72c 100644 --- a/src/Monnify/Http/MonnifyHttpClientBase.cs +++ b/src/Monnify/Http/MonnifyHttpClientBase.cs @@ -35,7 +35,7 @@ protected async Task SendVoidAsync(HttpRequestMessage request, CancellationToken throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex); } - if (envelope is null || !envelope.RequestSuccessful) + if (envelope is null || envelope.RequestSuccessful == false) { throw new MonnifyApiException( envelope?.ResponseCode ?? "UNKNOWN", @@ -63,7 +63,13 @@ protected async Task SendAsync(HttpRequestMessage throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex); } - if (envelope is null || !envelope.RequestSuccessful) + // Treat as a definitive failure if RequestSuccessful is explicitly false, or if it is + // absent (null) and there is no responseBody — which indicates a non-Monnify error body + // (e.g. a gateway rejection) rather than a paycode-style response that omits the field. + bool definiteFailure = envelope is null || envelope.RequestSuccessful == false; + bool ambiguousNoBody = envelope is not null && envelope.RequestSuccessful is null && envelope.ResponseBody is null; + + if (definiteFailure || ambiguousNoBody) { throw new MonnifyApiException( envelope?.ResponseCode ?? "UNKNOWN", @@ -72,7 +78,7 @@ protected async Task SendAsync(HttpRequestMessage json); } - if (envelope.ResponseBody is null) + if (envelope!.ResponseBody is null) { throw new MonnifyDeserializationException( $"Monnify reported success but returned no {typeof(TResponseBody).Name} response body.", json, diff --git a/src/Monnify/Http/MonnifyResponseEnvelope.cs b/src/Monnify/Http/MonnifyResponseEnvelope.cs index d5cd686..e290780 100644 --- a/src/Monnify/Http/MonnifyResponseEnvelope.cs +++ b/src/Monnify/Http/MonnifyResponseEnvelope.cs @@ -5,8 +5,13 @@ namespace Monnify.Http; /// Monnify's standard response envelope, observed across the endpoints checked so far. internal sealed class MonnifyResponseEnvelope { + /// + /// Most endpoints send this as true/false; a few (e.g. paycodes) omit it entirely. + /// Deserializes as null when absent — treated as a success attempt by the base client. + /// Only an explicit false is a definitive API failure. + /// [JsonPropertyName("requestSuccessful")] - public bool RequestSuccessful { get; set; } + public bool? RequestSuccessful { get; set; } // Nullable with no default: a missing field must deserialize to null, not "", so callers' // `envelope.ResponseMessage ?? fallback` actually fires for non-Monnify-shaped error bodies diff --git a/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs b/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs index 3e34c33..6c98767 100644 --- a/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs +++ b/tests/Monnify.IntegrationTests/Collections/CollectionsSandboxTests.cs @@ -249,4 +249,26 @@ public async Task TransactionLookup_AgainstRealSandbox_InitiateBankTransferSearc var queried = await client.QueryTransactionAsync(paymentReference: reference); Assert.Equal(reference, queried.PaymentReference); } + + [SkippableFact] + public async Task PaycodeReadOperations_AgainstRealSandbox_ListGetAndAuthorize() + { + Skip.IfNot(SandboxCredentials.IsAvailable, "Sandbox credentials are not set."); + + var client = _fixture.Provider.GetRequiredService(); + + var page = await client.GetPaycodesAsync(); + Assert.NotNull(page.Content); + Assert.True(page.TotalElements > 0, "Expected at least one paycode in the sandbox."); + + var first = page.Content[0]; + Assert.False(string.IsNullOrWhiteSpace(first.PaycodeReference)); + + var byRef = await client.GetPaycodeAsync(first.PaycodeReference); + Assert.Equal(first.PaycodeReference, byRef.PaycodeReference); + + var unmasked = await client.GetUnmaskedPaycodeAsync(first.PaycodeReference); + Assert.Equal(first.PaycodeReference, unmasked.PaycodeReference); + Assert.False(unmasked.PaycodeValue.StartsWith("***"), "Authorize endpoint should return unmasked paycode."); + } } diff --git a/tests/Monnify.IntegrationTests/Disbursements/DisbursementsSandboxTests.cs b/tests/Monnify.IntegrationTests/Disbursements/DisbursementsSandboxTests.cs index 96dcf87..f0e255d 100644 --- a/tests/Monnify.IntegrationTests/Disbursements/DisbursementsSandboxTests.cs +++ b/tests/Monnify.IntegrationTests/Disbursements/DisbursementsSandboxTests.cs @@ -107,4 +107,26 @@ public async Task SharedEndpoints_AgainstRealSandbox_WalletBalanceAndSearch() var page = await client.SearchTransactionsAsync(SandboxDisbursementWallet.Value!, pageNo: 0, pageSize: 3); Assert.NotNull(page.Content); } + + [SkippableFact] + public async Task CustomerWallets_AgainstRealSandbox_ListGetBalanceAndTransactions() + { + Skip.IfNot(SandboxCredentials.IsAvailable, "Sandbox credentials are not set."); + + var client = _fixture.Provider.GetRequiredService(); + + var page = await client.GetWalletsAsync(pageNo: 0, pageSize: 5); + Assert.NotNull(page.Content); + Assert.True(page.TotalElements > 0, "Expected at least one customer wallet in the sandbox."); + + var wallet = page.Content[0]; + Assert.False(string.IsNullOrWhiteSpace(wallet.WalletReference)); + Assert.False(string.IsNullOrWhiteSpace(wallet.AccountNumber)); + + var balance = await client.GetCustomerWalletBalanceAsync(wallet.AccountNumber); + Assert.True(balance.AvailableBalance >= 0); + + var transactions = await client.GetWalletTransactionsAsync(wallet.AccountNumber, pageNo: 0, pageSize: 5); + Assert.NotNull(transactions.Content); + } } diff --git a/tests/Monnify.Tests/Collections/Paycodes/MonnifyCollectionsClientPaycodeTests.cs b/tests/Monnify.Tests/Collections/Paycodes/MonnifyCollectionsClientPaycodeTests.cs new file mode 100644 index 0000000..9ee5749 --- /dev/null +++ b/tests/Monnify.Tests/Collections/Paycodes/MonnifyCollectionsClientPaycodeTests.cs @@ -0,0 +1,218 @@ +using System.Net; +using Monnify.Collections; +using Monnify.Tests.TestUtilities; + +namespace Monnify.Tests.Collections.Paycodes; + +public class MonnifyCollectionsClientPaycodeTests +{ + private static MonnifyCollectionsClient CreateClient(FakeHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); + + private const string PaycodeJson = """ + { + "paycode": "11467409", + "transactionReference": "MFY-39A78F78E6C341759ACA344297A8CF70", + "paycodeReference": "ghehdekdkefkefjekjfejj", + "beneficiaryName": "Marvelous Benji", + "amount": 50, + "fee": 100, + "transactionStatus": "PENDING", + "expiryDate": "2023-02-19 11:00:26", + "createdOn": "2023-02-16T12:32:01.591+0000", + "createdBy": "MK_PROD_WTZLS10MX6", + "modifiedBy": "MK_PROD_WTZLS10MX6" + } + """; + + private static string EnvelopeOf(string body) => + $$$"""{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", "responseBody": {{{body}}} }"""; + + // ── CreatePaycodeAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task CreatePaycodeAsync_SendsPostWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, EnvelopeOf(PaycodeJson))); + var client = CreateClient(handler); + + var result = await client.CreatePaycodeAsync(new CreatePaycodeRequest + { + BeneficiaryName = "Marvelous Benji", + Amount = 50, + PaycodeReference = "ghehdekdkefkefjekjfejj", + ExpiryDate = "2023-10-18 19:00:26", + ClientId = "MK_PROD_GFVLE0PZTQ", + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/paycode", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"paycodeReference\":\"ghehdekdkefkefjekjfejj\"", handler.RequestBodies[0]); + Assert.Contains("\"clientId\":\"MK_PROD_GFVLE0PZTQ\"", handler.RequestBodies[0]); + Assert.Equal("11467409", result.PaycodeValue); + Assert.Equal(50m, result.Amount); + Assert.Equal("PENDING", result.TransactionStatus); + } + + [Fact] + public async Task CreatePaycodeAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.CreatePaycodeAsync(null!)); + } + + // ── GetPaycodesAsync ────────────────────────────────────────────────────── + + [Fact] + public async Task GetPaycodesAsync_NoFilter_SendsGetWithNoQueryString() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [ {{PaycodeJson}} ], + "last": true, "totalElements": 1, "totalPages": 1, + "first": true, "numberOfElements": 1, "size": 10, "number": 0, "empty": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.GetPaycodesAsync(); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/paycode", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("", handler.Requests[0].RequestUri!.Query); + Assert.Single(result.Content); + Assert.Equal("ghehdekdkefkefjekjfejj", result.Content[0].PaycodeReference); + } + + [Fact] + public async Task GetPaycodesAsync_WithAllFilters_IncludesAllQueryParams() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [], "last": true, "totalElements": 0, "totalPages": 0, + "first": true, "numberOfElements": 0, "size": 10, "number": 0, "empty": true + } } + """)); + var client = CreateClient(handler); + + await client.GetPaycodesAsync(new PaycodeSearchFilter + { + TransactionReference = "MFY-ABC", + BeneficiaryName = "John", + TransactionStatus = "PENDING", + From = 1676541600, + To = 1676628000, + }); + + var query = handler.Requests[0].RequestUri!.Query; + Assert.Contains("transactionReference=MFY-ABC", query); + Assert.Contains("beneficiaryName=John", query); + Assert.Contains("transactionStatus=PENDING", query); + Assert.Contains("from=1676541600", query); + Assert.Contains("to=1676628000", query); + } + + [Fact] + public async Task GetPaycodesAsync_WithPartialFilter_OmitsNullParams() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [], "last": true, "totalElements": 0, "totalPages": 0, + "first": true, "numberOfElements": 0, "size": 10, "number": 0, "empty": true + } } + """)); + var client = CreateClient(handler); + + await client.GetPaycodesAsync(new PaycodeSearchFilter { TransactionStatus = "PENDING" }); + + var query = handler.Requests[0].RequestUri!.Query; + Assert.Contains("transactionStatus=PENDING", query); + Assert.DoesNotContain("transactionReference", query); + Assert.DoesNotContain("beneficiaryName", query); + Assert.DoesNotContain("from=", query); + Assert.DoesNotContain("to=", query); + } + + // ── GetPaycodeAsync ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetPaycodeAsync_SendsGetToCorrectPath() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, EnvelopeOf(PaycodeJson))); + var client = CreateClient(handler); + + var result = await client.GetPaycodeAsync("ghehdekdkefkefjekjfejj"); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/paycode/ghehdekdkefkefjekjfejj", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("ghehdekdkefkefjekjfejj", result.PaycodeReference); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetPaycodeAsync_MissingReference_Throws(string? reference) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.GetPaycodeAsync(reference!)); + } + + // ── CancelPaycodeAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task CancelPaycodeAsync_SendsDeleteToCorrectPath_AndReturnsCancelledPaycode() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, EnvelopeOf(PaycodeJson))); + var client = CreateClient(handler); + + var result = await client.CancelPaycodeAsync("ghehdekdkefkefjekjfejj"); + + Assert.Equal(HttpMethod.Delete, handler.Requests[0].Method); + Assert.Equal("/api/v1/paycode/ghehdekdkefkefjekjfejj", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("11467409", result.PaycodeValue); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task CancelPaycodeAsync_MissingReference_Throws(string? reference) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.CancelPaycodeAsync(reference!)); + } + + // ── GetUnmaskedPaycodeAsync ─────────────────────────────────────────────── + + [Fact] + public async Task GetUnmaskedPaycodeAsync_SendsGetToAuthorizePath() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, EnvelopeOf(PaycodeJson))); + var client = CreateClient(handler); + + var result = await client.GetUnmaskedPaycodeAsync("ghehdekdkefkefjekjfejj"); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/paycode/ghehdekdkefkefjekjfejj/authorize", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("11467409", result.PaycodeValue); + Assert.Equal(100m, result.Fee); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetUnmaskedPaycodeAsync_MissingReference_Throws(string? reference) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.GetUnmaskedPaycodeAsync(reference!)); + } +} diff --git a/tests/Monnify.Tests/Disbursements/MonnifyDisbursementsClientWalletTests.cs b/tests/Monnify.Tests/Disbursements/MonnifyDisbursementsClientWalletTests.cs new file mode 100644 index 0000000..d7ecfb0 --- /dev/null +++ b/tests/Monnify.Tests/Disbursements/MonnifyDisbursementsClientWalletTests.cs @@ -0,0 +1,182 @@ +using System.Net; +using Monnify.Disbursements; +using Monnify.Tests.TestUtilities; + +namespace Monnify.Tests.Disbursements; + +public class MonnifyDisbursementsClientWalletTests +{ + private static MonnifyDisbursementsClient CreateClient(FakeHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://sandbox.monnify.test") }); + + private const string WalletJson = """ + { + "walletName": "Staging Wallet - ref1684248425966", + "walletReference": "ref1684248425966", + "customerName": "John Doe", + "customerEmail": "johndoe@example.com", + "feeBearer": "SELF", + "bvnDetails": { "bvn": "22222222226", "bvnDateOfBirth": "1994-09-07" }, + "accountNumber": "1234567890", + "accountName": "John Doe", + "topUpAccountDetails": { + "accountNumber": "1234567890", + "accountName": "John Doe", + "bankCode": "50515", + "bankName": "Moniepoint MFB", + "createdOn": "2025-11-12T11:40:25.344+00:00" + } + } + """; + + [Fact] + public async Task CreateWalletAsync_SendsPostWithJsonBody_AndDeserializesResult() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, + $$"""{ "requestSuccessful": true, "responseMessage": "Success", "responseCode": "0", "responseBody": {{WalletJson}} }""")); + var client = CreateClient(handler); + + var result = await client.CreateWalletAsync(new CreateWalletRequest + { + WalletReference = "ref1684248425966", + WalletName = "Staging Wallet - ref1684248425966", + CustomerName = "John Doe", + CustomerEmail = "johndoe@example.com", + BvnDetails = new BvnDetails { Bvn = "22222222226", BvnDateOfBirth = "1994-09-07" }, + }); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal("/api/v1/disbursements/wallet", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("\"walletReference\":\"ref1684248425966\"", handler.RequestBodies[0]); + Assert.Contains("\"bvn\":\"22222222226\"", handler.RequestBodies[0]); + Assert.Equal("ref1684248425966", result.WalletReference); + Assert.Equal("SELF", result.FeeBearer); + Assert.Equal("50515", result.TopUpAccountDetails!.BankCode); + } + + [Fact] + public async Task CreateWalletAsync_NullRequest_Throws() + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.CreateWalletAsync(null!)); + } + + [Fact] + public async Task GetWalletsAsync_WithoutFilter_SendsGetWithOnlyPaging() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [ {{WalletJson}} ], + "last": true, "totalElements": 1, "totalPages": 1, + "first": true, "numberOfElements": 1, "size": 10, "number": 0, "empty": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.GetWalletsAsync(pageNo: 0, pageSize: 10); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/disbursements/wallet", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("pageNo=0&pageSize=10", handler.Requests[0].RequestUri!.Query.TrimStart('?')); + Assert.Single(result.Content); + Assert.Equal("ref1684248425966", result.Content[0].WalletReference); + } + + [Fact] + public async Task GetWalletsAsync_WithWalletReference_IncludesFilterInQuery() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, $$""" + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [ {{WalletJson}} ], + "last": true, "totalElements": 1, "totalPages": 1, + "first": true, "numberOfElements": 1, "size": 10, "number": 0, "empty": false + } } + """)); + var client = CreateClient(handler); + + await client.GetWalletsAsync(walletReference: "ref1684248425966", pageNo: 0, pageSize: 10); + + Assert.Contains("walletReference=ref1684248425966", handler.Requests[0].RequestUri!.Query); + } + + [Fact] + public async Task GetCustomerWalletBalanceAsync_SendsGetWithAccountNumberParam() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { "availableBalance": 5000000000, "ledgerBalance": 5000000000 } } + """)); + var client = CreateClient(handler); + + var result = await client.GetCustomerWalletBalanceAsync("2611645927"); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/disbursements/wallet/balance", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("accountNumber=2611645927", handler.Requests[0].RequestUri!.Query.TrimStart('?')); + Assert.Equal(5000000000m, result.AvailableBalance); + Assert.Equal(5000000000m, result.LedgerBalance); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetCustomerWalletBalanceAsync_MissingAccountNumber_Throws(string? accountNumber) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.GetCustomerWalletBalanceAsync(accountNumber!)); + } + + [Fact] + public async Task GetWalletTransactionsAsync_SendsGetWithAccountNumberAndPaging() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """ + { "requestSuccessful": true, "responseMessage": "success", "responseCode": "0", + "responseBody": { + "content": [ + { + "walletTransactionReference": "MFDS60520251111091433348001YWN0D9_DEBIT_0", + "monnifyTransactionReference": "MFDS60520251111091433348001YWN0D9_DEBIT_0", + "availableBalanceBefore": 5000000000, + "availableBalanceAfter": 4900000000, + "amount": 1000000000, + "transactionDate": "2025-11-11T08:14:33.000+00:00", + "transactionType": "DEBIT", + "message": null, + "narration": "test narration", + "status": "COMPLETED" + } + ], + "last": true, "totalElements": 1, "totalPages": 1, + "first": true, "numberOfElements": 1, "size": 10, "number": 0, "empty": false + } } + """)); + var client = CreateClient(handler); + + var result = await client.GetWalletTransactionsAsync("1234567890", pageNo: 0, pageSize: 10); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal("/api/v1/disbursements/wallet/transactions", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("accountNumber=1234567890", handler.Requests[0].RequestUri!.Query); + Assert.Single(result.Content); + Assert.Equal("DEBIT", result.Content[0].TransactionType); + Assert.Equal(5000000000m, result.Content[0].AvailableBalanceBefore); + Assert.Equal(1000000000m, result.Content[0].Amount); + Assert.Null(result.Content[0].Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetWalletTransactionsAsync_MissingAccountNumber_Throws(string? accountNumber) + { + var client = CreateClient(new FakeHttpMessageHandler()); + await Assert.ThrowsAsync(() => client.GetWalletTransactionsAsync(accountNumber!)); + } +}