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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ 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 `ChargeCardTokenAsync` (`IMonnifyCollectionsClient`) — charges a previously tokenized, reusable card in one call, no OTP/3DS follow-up

## [1.0.0](https://github.com/Monnify/monnify-dotnet-lib/compare/v0.11.0...v1.0.0) (2026-07-02)


Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
An idiomatic .NET SDK for the [Monnify](https://developers.monnify.com)
payment gateway API, targeting `netstandard2.0` and `net8.0`.

> **Status: early release (pre-1.0).** Published on NuGet.org starting with
> `0.1.0` — the public API may still change before a stable `1.0`. See
> [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) for every endpoint this SDK
> calls and how it was verified.
> **Status: stable (`1.0`).** Every endpoint this SDK calls has shipped. See
> [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) for each one's verification
> status — all are sandbox-verified except `ChargeCardTokenAsync`, which
> needs a reusable card token from a successful `ChargeAsync` call to test
> end-to-end, currently blocked by a sandbox card-BIN issue.

## Features

- **Collections** — hosted checkout, reserved (virtual) accounts, invoices,
bank-transfer payment links, transaction search
bank-transfer payment links, card transactions (charge, tokenized-card
charge, OTP, 3DS authorize), refunds, transaction limit profiles,
sub-accounts/splitting, direct debit mandates, paycodes, transaction search
- **Disbursements** — single and bulk transfers, OTP authorization, wallet
balance, transaction search
balance, customer wallets, transaction search
- **Bills payment** — airtime, data, cable TV, electricity, and other billers
- **Verification** — account name enquiry, BVN/NIN checks
- **Banks** — bank list, USSD-enabled banks
Expand Down
2 changes: 1 addition & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Status legend:
| `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 | |
| `IMonnifyCollectionsClient.ChargeCardTokenAsync` | `POST /api/v1/merchant/cards/charge-card-token` | **Implemented** | Charges a previously tokenized, reusable card in one call - no OTP/3DS follow-up. Response shape matches `Transaction` exactly (`cardDetails.cardToken`/`.reusable` already modeled there). Automatic retry disabled, same reasoning as `ChargeAsync` - directly debits a card. Not yet sandbox-verified end-to-end (needs a `reusable: true` card token from a prior successful charge, which the sandbox BIN issue on `ChargeAsync` currently blocks) |
| *(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 |
| `IMonnifyDisbursementsClient.AuthorizeSingleTransferAsync` | `POST /api/v2/disbursements/single/validate-otp` | **Implemented** | For a transfer with status `PENDING_AUTHORIZATION` |
Expand Down
8 changes: 8 additions & 0 deletions src/Monnify/Collections/IMonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ Task<BankTransferPaymentDetails> InitiateBankTransferAsync(
/// </summary>
Task<AuthorizeCardOtpResult> Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Charges a previously tokenized, reusable card in a single call - no raw card details, no
/// OTP/3DS follow-up. The token comes from <see cref="TransactionCardDetails.CardToken"/> on an
/// earlier <see cref="ChargeAsync"/> result where <see cref="TransactionCardDetails.Reusable"/>
/// was <see langword="true"/>; the customer email must match that original charge.
/// </summary>
Task<Transaction> ChargeCardTokenAsync(ChargeCardTokenRequest request, CancellationToken cancellationToken = default);

/// <summary>Searches transactions on the integration, optionally filtered by reference, amount range, customer, status, or date range.</summary>
Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;

namespace Monnify.Collections;

/// <summary>
/// Charges a previously tokenized, reusable card - see <see cref="TransactionCardDetails.CardToken"/>
/// on a prior charge's result (only present when <see cref="TransactionCardDetails.Reusable"/> is
/// <see langword="true"/>). Unlike <see cref="ChargeCardRequest"/>, this needs no raw card details
/// and completes in one call - no OTP/3DS follow-up.
/// </summary>
public sealed class ChargeCardTokenRequest
{
[JsonPropertyName("cardToken")]
public string CardToken { get; set; } = string.Empty;

[JsonPropertyName("amount")]
public decimal Amount { get; set; }

[JsonPropertyName("customerName")]
public string? CustomerName { get; set; }

/// <summary>Must match the customer email used on the original charge that produced this card token.</summary>
[JsonPropertyName("customerEmail")]
public string CustomerEmail { get; set; } = string.Empty;

[JsonPropertyName("paymentReference")]
public string PaymentReference { get; set; } = string.Empty;

[JsonPropertyName("paymentDescription")]
public string? PaymentDescription { get; set; }

[JsonPropertyName("currencyCode")]
public string CurrencyCode { get; set; } = "NGN";

[JsonPropertyName("contractCode")]
public string ContractCode { get; set; } = string.Empty;

/// <summary>The merchant's API key (your <c>MonnifyClientOptions.ApiKey</c>).</summary>
[JsonPropertyName("apiKey")]
public string ApiKey { get; set; } = string.Empty;

[JsonPropertyName("metaData")]
public IDictionary<string, object?>? MetaData { get; set; }

/// <summary>Splits this payment among sub-accounts, same as on <see cref="CreateInvoiceRequest"/>.</summary>
[JsonPropertyName("incomeSplitConfig")]
public IReadOnlyList<IncomeSplitConfig>? IncomeSplitConfig { get; set; }
}
14 changes: 14 additions & 0 deletions src/Monnify/Collections/MonnifyCollectionsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,20 @@ public Task<AuthorizeCardOtpResult> Authorize3dsAsync(Authorize3dsCardRequest re
return SendAsync<AuthorizeCardOtpResult>(httpRequest, cancellationToken);
}

public Task<Transaction> ChargeCardTokenAsync(ChargeCardTokenRequest request, CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Cards.ChargeToken)
{
Content = CreateJsonContent(request),
};
return SendAsync<Transaction>(httpRequest, cancellationToken);
}

public Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default)
{
Expand Down
1 change: 1 addition & 0 deletions src/Monnify/Http/MonnifyApiPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal static class Invoices
internal static class Cards
{
public const string Charge = "/api/v1/merchant/cards/charge";
public const string ChargeToken = "/api/v1/merchant/cards/charge-card-token";
public const string AuthorizeOtp = "/api/v1/merchant/cards/otp/authorize";
public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,57 @@ public async Task Authorize3dsAsync_NullRequest_Throws()
var client = CreateClient(new FakeHttpMessageHandler());
await Assert.ThrowsAsync<ArgumentNullException>(() => client.Authorize3dsAsync(null!));
}

[Fact]
public async Task ChargeCardTokenAsync_SendsPostWithJsonBody_AndDeserializesResult()
{
// Sample payload from our official API reference (POST /api/v1/merchant/cards/charge-card-token).
var handler = new FakeHttpMessageHandler();
handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
"responseBody": {
"transactionReference": "MNFY|87|20230602223418|007039",
"paymentReference": "1642776mml0068n2937",
"amountPaid": "20.00", "totalPayable": "20.00", "settlementAmount": "19.68",
"paidOn": "02/06/2023 10:34:26 PM", "paymentStatus": "PAID",
"paymentDescription": "Paying for Product A", "currency": "NGN", "paymentMethod": "CARD",
"product": { "type": "API_NOTIFICATION", "reference": "1642776mml0068n2937" },
"cardDetails": {
"cardType": "MasterCard", "last4": "9098", "expMonth": "07", "expYear": "23",
"bin": "539941", "bankCode": "057", "bankName": "Zenith bank", "reusable": true,
"countryCode": "string", "cardToken": "MNFY_0CD0138B45F7478E941C3EC6D3698969",
"supportsTokenization": false, "maskedPan": "539941******9098"
},
"customer": { "email": "benjikali29@gmail.com", "name": "Marvelous Benji" }
} }
"""));
var client = CreateClient(handler);

var result = await client.ChargeCardTokenAsync(new ChargeCardTokenRequest
{
CardToken = "MNFY_0CD0138B45F7478E941C3EC6D3698969",
Amount = 20,
CustomerName = "Marvelous Benji",
CustomerEmail = "benjikali29@gmail.com",
PaymentReference = "1642776mml0068n2937",
PaymentDescription = "Paying for Product A",
ContractCode = "5867418298",
ApiKey = "MK_TEST_PLACEHOLDER123",
});

Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
Assert.Equal("/api/v1/merchant/cards/charge-card-token", handler.Requests[0].RequestUri!.AbsolutePath);
Assert.Contains("\"cardToken\":\"MNFY_0CD0138B45F7478E941C3EC6D3698969\"", handler.RequestBodies[0]);
Assert.Equal("PAID", result.PaymentStatus);
Assert.Equal(20, result.AmountPaid);
Assert.Equal("MNFY_0CD0138B45F7478E941C3EC6D3698969", result.CardDetails!.CardToken);
Assert.True(result.CardDetails.Reusable);
}

[Fact]
public async Task ChargeCardTokenAsync_NullRequest_Throws()
{
var client = CreateClient(new FakeHttpMessageHandler());
await Assert.ThrowsAsync<ArgumentNullException>(() => client.ChargeCardTokenAsync(null!));
}
}
Loading