Skip to content

Commit 6b890b7

Browse files
authored
Merge pull request #34 from Monnify/dev
feat: add ChargeCardTokenAsync for charging a tokenized reusable card
2 parents f3257c8 + 82d3777 commit 6b890b7

8 files changed

Lines changed: 140 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Each entry that introduces or changes an API call should cross-reference the
99
relevant row in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md), since the SDK's
1010
own version is independent of Monnify's API versioning.
1111

12+
## [Unreleased]
13+
14+
### Features
15+
16+
* add `ChargeCardTokenAsync` (`IMonnifyCollectionsClient`) — charges a previously tokenized, reusable card in one call, no OTP/3DS follow-up
17+
1218
## [1.0.0](https://github.com/Monnify/monnify-dotnet-lib/compare/v0.11.0...v1.0.0) (2026-07-02)
1319

1420

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@
77
An idiomatic .NET SDK for the [Monnify](https://developers.monnify.com)
88
payment gateway API, targeting `netstandard2.0` and `net8.0`.
99

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

1718
- **Collections** — hosted checkout, reserved (virtual) accounts, invoices,
18-
bank-transfer payment links, transaction search
19+
bank-transfer payment links, card transactions (charge, tokenized-card
20+
charge, OTP, 3DS authorize), refunds, transaction limit profiles,
21+
sub-accounts/splitting, direct debit mandates, paycodes, transaction search
1922
- **Disbursements** — single and bulk transfers, OTP authorization, wallet
20-
balance, transaction search
23+
balance, customer wallets, transaction search
2124
- **Bills payment** — airtime, data, cable TV, electricity, and other billers
2225
- **Verification** — account name enquiry, BVN/NIN checks
2326
- **Banks** — bank list, USSD-enabled banks

docs/COMPATIBILITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Status legend:
5050
| `IMonnifyCollectionsClient.GetPaycodeAsync` | `GET /api/v1/paycode/{paycodeReference}` | **Implemented** | Returns masked paycode; response omits `requestSuccessful` field (handled via nullable envelope) |
5151
| `IMonnifyCollectionsClient.CancelPaycodeAsync` | `DELETE /api/v1/paycode/{paycodeReference}` | **Implemented** | Returns the cancelled paycode object; sandbox discovered undocumented `cancelDate` field |
5252
| `IMonnifyCollectionsClient.GetUnmaskedPaycodeAsync` | `GET /api/v1/paycode/{paycodeReference}/authorize` | **Implemented** | Returns the unmasked paycode value (e.g. `04797046` instead of `******46`) |
53-
| *(Collections — card tokenization)* | TBD | Planned | |
53+
| `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) |
5454
| *(Collections — Payment Links)* | N/A | Out of scope | Dashboard-only feature, no API |
5555
| `IMonnifyDisbursementsClient.InitiateSingleTransferAsync` | `POST /api/v2/disbursements/single` | **Implemented** | Requires Transfer feature activation (sales@monnify.com); automatic retry disabled |
5656
| `IMonnifyDisbursementsClient.AuthorizeSingleTransferAsync` | `POST /api/v2/disbursements/single/validate-otp` | **Implemented** | For a transfer with status `PENDING_AUTHORIZATION` |

src/Monnify/Collections/IMonnifyCollectionsClient.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ Task<BankTransferPaymentDetails> InitiateBankTransferAsync(
6767
/// </summary>
6868
Task<AuthorizeCardOtpResult> Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default);
6969

70+
/// <summary>
71+
/// Charges a previously tokenized, reusable card in a single call - no raw card details, no
72+
/// OTP/3DS follow-up. The token comes from <see cref="TransactionCardDetails.CardToken"/> on an
73+
/// earlier <see cref="ChargeAsync"/> result where <see cref="TransactionCardDetails.Reusable"/>
74+
/// was <see langword="true"/>; the customer email must match that original charge.
75+
/// </summary>
76+
Task<Transaction> ChargeCardTokenAsync(ChargeCardTokenRequest request, CancellationToken cancellationToken = default);
77+
7078
/// <summary>Searches transactions on the integration, optionally filtered by reference, amount range, customer, status, or date range.</summary>
7179
Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
7280
SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Monnify.Collections;
4+
5+
/// <summary>
6+
/// Charges a previously tokenized, reusable card - see <see cref="TransactionCardDetails.CardToken"/>
7+
/// on a prior charge's result (only present when <see cref="TransactionCardDetails.Reusable"/> is
8+
/// <see langword="true"/>). Unlike <see cref="ChargeCardRequest"/>, this needs no raw card details
9+
/// and completes in one call - no OTP/3DS follow-up.
10+
/// </summary>
11+
public sealed class ChargeCardTokenRequest
12+
{
13+
[JsonPropertyName("cardToken")]
14+
public string CardToken { get; set; } = string.Empty;
15+
16+
[JsonPropertyName("amount")]
17+
public decimal Amount { get; set; }
18+
19+
[JsonPropertyName("customerName")]
20+
public string? CustomerName { get; set; }
21+
22+
/// <summary>Must match the customer email used on the original charge that produced this card token.</summary>
23+
[JsonPropertyName("customerEmail")]
24+
public string CustomerEmail { get; set; } = string.Empty;
25+
26+
[JsonPropertyName("paymentReference")]
27+
public string PaymentReference { get; set; } = string.Empty;
28+
29+
[JsonPropertyName("paymentDescription")]
30+
public string? PaymentDescription { get; set; }
31+
32+
[JsonPropertyName("currencyCode")]
33+
public string CurrencyCode { get; set; } = "NGN";
34+
35+
[JsonPropertyName("contractCode")]
36+
public string ContractCode { get; set; } = string.Empty;
37+
38+
/// <summary>The merchant's API key (your <c>MonnifyClientOptions.ApiKey</c>).</summary>
39+
[JsonPropertyName("apiKey")]
40+
public string ApiKey { get; set; } = string.Empty;
41+
42+
[JsonPropertyName("metaData")]
43+
public IDictionary<string, object?>? MetaData { get; set; }
44+
45+
/// <summary>Splits this payment among sub-accounts, same as on <see cref="CreateInvoiceRequest"/>.</summary>
46+
[JsonPropertyName("incomeSplitConfig")]
47+
public IReadOnlyList<IncomeSplitConfig>? IncomeSplitConfig { get; set; }
48+
}

src/Monnify/Collections/MonnifyCollectionsClient.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,20 @@ public Task<AuthorizeCardOtpResult> Authorize3dsAsync(Authorize3dsCardRequest re
244244
return SendAsync<AuthorizeCardOtpResult>(httpRequest, cancellationToken);
245245
}
246246

247+
public Task<Transaction> ChargeCardTokenAsync(ChargeCardTokenRequest request, CancellationToken cancellationToken = default)
248+
{
249+
if (request is null)
250+
{
251+
throw new ArgumentNullException(nameof(request));
252+
}
253+
254+
var httpRequest = new HttpRequestMessage(HttpMethod.Post, MonnifyApiPaths.Collections.Cards.ChargeToken)
255+
{
256+
Content = CreateJsonContent(request),
257+
};
258+
return SendAsync<Transaction>(httpRequest, cancellationToken);
259+
}
260+
247261
public Task<MonnifyPagedResult<TransactionSummary>> SearchTransactionsAsync(
248262
SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default)
249263
{

src/Monnify/Http/MonnifyApiPaths.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ internal static class Invoices
5454
internal static class Cards
5555
{
5656
public const string Charge = "/api/v1/merchant/cards/charge";
57+
public const string ChargeToken = "/api/v1/merchant/cards/charge-card-token";
5758
public const string AuthorizeOtp = "/api/v1/merchant/cards/otp/authorize";
5859
public const string Authorize3ds = "/api/v1/sdk/cards/secure-3d/authorize";
5960
}

tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,57 @@ public async Task Authorize3dsAsync_NullRequest_Throws()
159159
var client = CreateClient(new FakeHttpMessageHandler());
160160
await Assert.ThrowsAsync<ArgumentNullException>(() => client.Authorize3dsAsync(null!));
161161
}
162+
163+
[Fact]
164+
public async Task ChargeCardTokenAsync_SendsPostWithJsonBody_AndDeserializesResult()
165+
{
166+
// Sample payload from our official API reference (POST /api/v1/merchant/cards/charge-card-token).
167+
var handler = new FakeHttpMessageHandler();
168+
handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, """
169+
{ "requestSuccessful": true, "responseMessage": "success", "responseCode": "0",
170+
"responseBody": {
171+
"transactionReference": "MNFY|87|20230602223418|007039",
172+
"paymentReference": "1642776mml0068n2937",
173+
"amountPaid": "20.00", "totalPayable": "20.00", "settlementAmount": "19.68",
174+
"paidOn": "02/06/2023 10:34:26 PM", "paymentStatus": "PAID",
175+
"paymentDescription": "Paying for Product A", "currency": "NGN", "paymentMethod": "CARD",
176+
"product": { "type": "API_NOTIFICATION", "reference": "1642776mml0068n2937" },
177+
"cardDetails": {
178+
"cardType": "MasterCard", "last4": "9098", "expMonth": "07", "expYear": "23",
179+
"bin": "539941", "bankCode": "057", "bankName": "Zenith bank", "reusable": true,
180+
"countryCode": "string", "cardToken": "MNFY_0CD0138B45F7478E941C3EC6D3698969",
181+
"supportsTokenization": false, "maskedPan": "539941******9098"
182+
},
183+
"customer": { "email": "benjikali29@gmail.com", "name": "Marvelous Benji" }
184+
} }
185+
"""));
186+
var client = CreateClient(handler);
187+
188+
var result = await client.ChargeCardTokenAsync(new ChargeCardTokenRequest
189+
{
190+
CardToken = "MNFY_0CD0138B45F7478E941C3EC6D3698969",
191+
Amount = 20,
192+
CustomerName = "Marvelous Benji",
193+
CustomerEmail = "benjikali29@gmail.com",
194+
PaymentReference = "1642776mml0068n2937",
195+
PaymentDescription = "Paying for Product A",
196+
ContractCode = "5867418298",
197+
ApiKey = "MK_TEST_PLACEHOLDER123",
198+
});
199+
200+
Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
201+
Assert.Equal("/api/v1/merchant/cards/charge-card-token", handler.Requests[0].RequestUri!.AbsolutePath);
202+
Assert.Contains("\"cardToken\":\"MNFY_0CD0138B45F7478E941C3EC6D3698969\"", handler.RequestBodies[0]);
203+
Assert.Equal("PAID", result.PaymentStatus);
204+
Assert.Equal(20, result.AmountPaid);
205+
Assert.Equal("MNFY_0CD0138B45F7478E941C3EC6D3698969", result.CardDetails!.CardToken);
206+
Assert.True(result.CardDetails.Reusable);
207+
}
208+
209+
[Fact]
210+
public async Task ChargeCardTokenAsync_NullRequest_Throws()
211+
{
212+
var client = CreateClient(new FakeHttpMessageHandler());
213+
await Assert.ThrowsAsync<ArgumentNullException>(() => client.ChargeCardTokenAsync(null!));
214+
}
162215
}

0 commit comments

Comments
 (0)