Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 11 additions & 18 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion src/Monnify/Authentication/MonnifyTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ private async Task<MonnifyAccessToken> 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"}.");
Expand Down
19 changes: 19 additions & 0 deletions src/Monnify/Collections/IMonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,23 @@ Task<IReadOnlyList<SubAccount>> CreateSubAccountsAsync(
/// </summary>
Task<MandateListResult> ListMandatesAsync(
ListMandatesFilter filter, int page = 0, int limit = 20, CancellationToken cancellationToken = default);

/// <summary>Creates a paycode that a beneficiary can use to collect cash at an agent point.</summary>
Task<Paycode> CreatePaycodeAsync(CreatePaycodeRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Returns a paged history of generated paycodes, optionally filtered by transaction
/// reference, beneficiary name, status, or a date range (unix timestamps).
/// </summary>
Task<MonnifyPagedResult<Paycode>> GetPaycodesAsync(
PaycodeSearchFilter? filter = null, CancellationToken cancellationToken = default);

/// <summary>Returns the details of a specific paycode by its merchant reference.</summary>
Task<Paycode> GetPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default);

/// <summary>Cancels an active paycode so it can no longer be used for cash collection.</summary>
Task<Paycode> CancelPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default);

/// <summary>Returns the unmasked (clear) paycode value for a given paycode reference.</summary>
Task<Paycode> GetUnmaskedPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default);
}
23 changes: 23 additions & 0 deletions src/Monnify/Collections/Models/Paycodes/CreatePaycodeRequest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Format: <c>YYYY-MM-DD HH:MM:SS</c>.</summary>
[JsonPropertyName("expiryDate")]
public string ExpiryDate { get; set; } = string.Empty;

/// <summary>The merchant's API key (your <c>MonnifyClientOptions.ApiKey</c>).</summary>
[JsonPropertyName("clientId")]
public string ClientId { get; set; } = string.Empty;
}
48 changes: 48 additions & 0 deletions src/Monnify/Collections/Models/Paycodes/Paycode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

public sealed class Paycode
{
/// <summary>The generated numeric code the beneficiary uses to collect cash.</summary>
[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; }

/// <summary>E.g. <c>PENDING</c>, <c>COMPLETED</c>, <c>CANCELLED</c>.</summary>
[JsonPropertyName("transactionStatus")]
public string TransactionStatus { get; set; } = string.Empty;

/// <summary>Format: <c>YYYY-MM-DD HH:MM:SS</c>.</summary>
[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;

/// <summary>Populated once the paycode has been cancelled; absent otherwise.</summary>
[JsonPropertyName("cancelDate")]
public string? CancelDate { get; set; }
}
14 changes: 14 additions & 0 deletions src/Monnify/Collections/Models/Paycodes/PaycodeSearchFilter.cs
Original file line number Diff line number Diff line change
@@ -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; }

/// <summary>Unix timestamp (seconds) for the start of the date range.</summary>
public long? From { get; set; }

/// <summary>Unix timestamp (seconds) for the end of the date range.</summary>
public long? To { get; set; }
}
54 changes: 54 additions & 0 deletions src/Monnify/Collections/MonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,60 @@ public Task<MandateListResult> ListMandatesAsync(
return SendAsync<MandateListResult>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
}

public Task<Paycode> 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<Paycode>(httpRequest, cancellationToken);
}

public Task<MonnifyPagedResult<Paycode>> GetPaycodesAsync(
PaycodeSearchFilter? filter = null, CancellationToken cancellationToken = default)
{
var query = new List<string>();
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<MonnifyPagedResult<Paycode>>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
}

public Task<Paycode> GetPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default)
{
RequireValue(paycodeReference, nameof(paycodeReference));
var path = $"{MonnifyApiPaths.Collections.Paycodes.Base}/{Uri.EscapeDataString(paycodeReference)}";
return SendAsync<Paycode>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
}

public Task<Paycode> CancelPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default)
{
RequireValue(paycodeReference, nameof(paycodeReference));
var path = $"{MonnifyApiPaths.Collections.Paycodes.Base}/{Uri.EscapeDataString(paycodeReference)}";
return SendAsync<Paycode>(new HttpRequestMessage(HttpMethod.Delete, path), cancellationToken);
}

public Task<Paycode> GetUnmaskedPaycodeAsync(string paycodeReference, CancellationToken cancellationToken = default)
{
RequireValue(paycodeReference, nameof(paycodeReference));
var path = $"{MonnifyApiPaths.Collections.Paycodes.Base}/{Uri.EscapeDataString(paycodeReference)}/authorize";
return SendAsync<Paycode>(new HttpRequestMessage(HttpMethod.Get, path), cancellationToken);
}

private static void AppendIfPresent(List<string> query, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
Expand Down
20 changes: 20 additions & 0 deletions src/Monnify/Disbursements/IMonnifyDisbursementsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,24 @@ Task<MonnifyPagedResult<DisbursementTransaction>> SearchTransactionsAsync(
int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default);

Task<WalletBalance> GetWalletBalanceAsync(string accountNumber, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a wallet for a customer. Requires the wallet feature to be enabled for the
/// merchant — contact sales@monnify.com to request access.
/// </summary>
Task<CustomerWallet> CreateWalletAsync(CreateWalletRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Returns all customer wallets on the integration, optionally filtered by
/// <paramref name="walletReference"/> to retrieve a specific wallet.
/// </summary>
Task<MonnifyPagedResult<CustomerWallet>> GetWalletsAsync(
string? walletReference = null, int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default);

/// <summary>Returns the available and ledger balance for a specific customer wallet.</summary>
Task<WalletBalance> GetCustomerWalletBalanceAsync(string accountNumber, CancellationToken cancellationToken = default);

/// <summary>Returns transactions for a customer wallet, identified by its account number.</summary>
Task<MonnifyPagedResult<WalletTransaction>> GetWalletTransactionsAsync(
string accountNumber, int pageNo = 0, int pageSize = 10, CancellationToken cancellationToken = default);
}
13 changes: 13 additions & 0 deletions src/Monnify/Disbursements/Models/Wallets/BvnDetails.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Format: <c>yyyy-MM-dd</c>.</summary>
[JsonPropertyName("bvnDateOfBirth")]
public string BvnDateOfBirth { get; set; } = string.Empty;
}
21 changes: 21 additions & 0 deletions src/Monnify/Disbursements/Models/Wallets/CreateWalletRequest.cs
Original file line number Diff line number Diff line change
@@ -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();
}
38 changes: 38 additions & 0 deletions src/Monnify/Disbursements/Models/Wallets/CustomerWallet.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>E.g. <c>SELF</c> — who bears wallet fees.</summary>
[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; }

/// <summary>Only present in the list response, not in the create response.</summary>
[JsonPropertyName("createdOn")]
public string? CreatedOn { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading