From 82d377702d28f854e8efda989f5d817bcb10c921 Mon Sep 17 00:00:00 2001 From: Greenfonts Date: Thu, 2 Jul 2026 11:46:34 +0100 Subject: [PATCH] feat: add ChargeCardTokenAsync for charging a tokenized reusable card Implements the previously-"Planned" card tokenization endpoint (POST /api/v1/merchant/cards/charge-card-token) that had been parked since the original scaffolding and never revisited while other clients got built out. The response shape matches the existing Transaction model exactly - TransactionCardDetails already had cardToken/reusable/supportsTokenization fields from GetTransactionAsync, so no new response model was needed. Also updates the README to drop the stale "early release (pre-1.0)" status line and list everything actually shipped since it was last written. --- CHANGELOG.md | 6 +++ README.md | 15 +++--- docs/COMPATIBILITY.md | 2 +- .../Collections/IMonnifyCollectionsClient.cs | 8 +++ .../Transactions/ChargeCardTokenRequest.cs | 48 +++++++++++++++++ .../Collections/MonnifyCollectionsClient.cs | 14 +++++ src/Monnify/Http/MonnifyApiPaths.cs | 1 + .../MonnifyCollectionsClientCardsTests.cs | 53 +++++++++++++++++++ 8 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 src/Monnify/Collections/Models/Transactions/ChargeCardTokenRequest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7d8f5..24867cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 39455e5..3480d3f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index de21a46..d5789ce 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -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` | diff --git a/src/Monnify/Collections/IMonnifyCollectionsClient.cs b/src/Monnify/Collections/IMonnifyCollectionsClient.cs index 3bc9b43..3eda476 100644 --- a/src/Monnify/Collections/IMonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/IMonnifyCollectionsClient.cs @@ -67,6 +67,14 @@ Task InitiateBankTransferAsync( /// Task Authorize3dsAsync(Authorize3dsCardRequest request, CancellationToken cancellationToken = default); + /// + /// Charges a previously tokenized, reusable card in a single call - no raw card details, no + /// OTP/3DS follow-up. The token comes from on an + /// earlier result where + /// was ; the customer email must match that original charge. + /// + Task ChargeCardTokenAsync(ChargeCardTokenRequest request, CancellationToken cancellationToken = default); + /// Searches transactions on the integration, optionally filtered by reference, amount range, customer, status, or date range. Task> SearchTransactionsAsync( SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default); diff --git a/src/Monnify/Collections/Models/Transactions/ChargeCardTokenRequest.cs b/src/Monnify/Collections/Models/Transactions/ChargeCardTokenRequest.cs new file mode 100644 index 0000000..a0fe3bc --- /dev/null +++ b/src/Monnify/Collections/Models/Transactions/ChargeCardTokenRequest.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace Monnify.Collections; + +/// +/// Charges a previously tokenized, reusable card - see +/// on a prior charge's result (only present when is +/// ). Unlike , this needs no raw card details +/// and completes in one call - no OTP/3DS follow-up. +/// +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; } + + /// Must match the customer email used on the original charge that produced this card token. + [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; + + /// The merchant's API key (your MonnifyClientOptions.ApiKey). + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } = string.Empty; + + [JsonPropertyName("metaData")] + public IDictionary? MetaData { get; set; } + + /// Splits this payment among sub-accounts, same as on . + [JsonPropertyName("incomeSplitConfig")] + public IReadOnlyList? IncomeSplitConfig { get; set; } +} diff --git a/src/Monnify/Collections/MonnifyCollectionsClient.cs b/src/Monnify/Collections/MonnifyCollectionsClient.cs index ebe38e2..58ffec5 100644 --- a/src/Monnify/Collections/MonnifyCollectionsClient.cs +++ b/src/Monnify/Collections/MonnifyCollectionsClient.cs @@ -244,6 +244,20 @@ public Task Authorize3dsAsync(Authorize3dsCardRequest re return SendAsync(httpRequest, cancellationToken); } + public Task 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(httpRequest, cancellationToken); + } + public Task> SearchTransactionsAsync( SearchTransactionsRequest? filter = null, int page = 0, int size = 10, CancellationToken cancellationToken = default) { diff --git a/src/Monnify/Http/MonnifyApiPaths.cs b/src/Monnify/Http/MonnifyApiPaths.cs index bc4c8ae..01676d8 100644 --- a/src/Monnify/Http/MonnifyApiPaths.cs +++ b/src/Monnify/Http/MonnifyApiPaths.cs @@ -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"; } diff --git a/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs b/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs index aff5034..e8da65d 100644 --- a/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs +++ b/tests/Monnify.Tests/Collections/Transactions/MonnifyCollectionsClientCardsTests.cs @@ -159,4 +159,57 @@ public async Task Authorize3dsAsync_NullRequest_Throws() var client = CreateClient(new FakeHttpMessageHandler()); await Assert.ThrowsAsync(() => 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(() => client.ChargeCardTokenAsync(null!)); + } }