Streamlining Payouts: A Guide to Integrating Paystack Withdrawals in a .NET Application

Paystack will always be remembered for facilitating payments across the African continent which has enabled businesses scale beyond borders. I had earlier written a tutorial on how to integrate their endpoint for deposit transaction in a DotNet application. However, I found implementing their payout/withdrawal endpoint a lot tricky and not very straight forward. Hence, the reason why I am putting this material out here to help the developer community that use this amazing product.

In order not to bore you with much words, My recomendation on how paystack payout system can better serve the developer community will make the last portion of this article.

This article will give a stepwise guide to integrating Paystack payout API.

Prerequisites:

  1. A Paystack account: Sign up for a Paystack account at https://paystack.com/.

  2. .NET Application: Make sure you have a .NET application ready for integration.

  3. Paystack API Keys: Obtain your Paystack API keys from the Paystack dashboard.

I would usualy keep my code base tidy using Repository and UnitOfWork.

  1. IPaymentService
using Demo.Models.ViewModels;

namespace Demo.Services.PaymentGateway
{
    public interface IPaymentService
    {
        public Task<Tuple<bool, string>> Withdraw(CreateWithdrawalVM model);
        public Task<bool> Verify(string reference);
        public Task<IEnumerable<Bank>> GetListOfBanks();
    }
}
  1. PaystackService.cs
using PayStack.Net;
using Demo.Data;
using Demo.Models.Entities;
using Demo.Models.ViewModels;
using Demo.Services.Repositories;
using Demo.Utilities;
using Bank = Demo.Models.ViewModels.Bank;

namespace Demo.Services.PaymentGateway;

public class PayStackService : IPaymentService
{
    private readonly IConfiguration _configuration;
    private readonly IRepository _repository;
    private readonly string _secretKey;
    private readonly PayStackApi _payStack;
    public string Url { get; set; }

    public PayStackService(IConfiguration configuration, DemoDbContext context, IRepository repository)
    {
        _configuration = configuration;
        _repository = repository;
        _secretKey = _configuration["Payment:PayStackSecretKey"]; //from AppSettings
        _payStack = new PayStackApi(_secretKey);
    }

    public async Task<IEnumerable<Bank>> GetListOfBanks()
    {
        var result = _payStack.Get<ApiResponse<dynamic>>("bank?currency=NGN");

        if (!result.Status)
            throw new Exception("Unable to fetch banks");

        var banks = result.Data.ToObject<List<Bank>>();

        return banks;
    }
}
  1. PaystackService.cs
using PayStack.Net;
using Demo.Data;
using Demo.Models.Entities;
using Demo.Models.ViewModels;
using Demo.Services.Repositories;
using Demo.Utilities;
using Bank = Demo.Models.ViewModels.Bank;

namespace Demo.Services.PaymentGateway;

public class PayStackService : IPaymentService
{
    private readonly IConfiguration _configuration;
    private readonly IRepository _repository;
    private readonly string _secretKey;
    private readonly PayStackApi _payStack;
    public string Url { get; set; }

    public PayStackService(IConfiguration configuration, DemoDbContext context, IRepository repository)
    {
        _configuration = configuration;
        _repository = repository;
        _secretKey = _configuration["Payment:PayStackSecretKey"]; //from AppSettings
        _payStack = new PayStackApi(_secretKey);
    }

    public async Task<IEnumerable<Bank>> GetListOfBanks()
    {
        var result = _payStack.Get<ApiResponse<dynamic>>("bank?currency=NGN");

        if (!result.Status)
            throw new Exception("Unable to fetch banks");

        var banks = result.Data.ToObject<List<Bank>>();

        return banks;
    }

    public async Task<Tuple<bool, string>> Withdraw(CreateWithdrawalVM model)
    {
        var result = _payStack.Post<ApiResponse<dynamic>, dynamic>("transferrecipient", new
        {
            type = "nuban",
            name = model.AccountName,
            account_number = model.AccountNumber,
            bank_code = model.BankCode,
            currency = "NGN",
        });

        if (!result.Status)
            throw new Exception("Unable to create transfer recipient");

        var recipientCode = result.Data.recipient_code;

        var transactionRef = TransactionHelper.GenerateTransRef();
        return new Tuple<bool, string>(true, transactionRef);
    }
}
  1. IPayments.cs
using Demo.Models.ViewModels;

namespace Demo.Services.Payment;

public interface IPayments
{
    public Task<IEnumerable<Bank>> GetBanks();
    public Task<bool> Withdraw(CreateWithdrawalVM model, string userId);
    public Task<bool> Transfer(string senderId, string receiverId, decimal amount);
}
  1. Payments.cs (Implementation for IPayment interface)
using Microsoft.EntityFrameworkCore;
using Demo.Models.Entities;
using Demo.Models.Enums;
using Demo.Services.PaymentGateway;
using Demo.Services.Repositories;
using Demo.Utilities;

namespace Demo.Services.Payment
{
    public class Payments : IPayments
    {
        private readonly IPaymentService _paymentService;
        private readonly IRepository _repository;
        public Payments(IPaymentService paymentService, IRepository repository)
        {
            _paymentService = paymentService;
            _repository = repository;
        }

        public async Task<IEnumerable<Bank>> GetBanks()
        {
            return await _paymentService.GetListOfBanks();
        }

        public async Task<bool> Withdraw(CreateWithdrawalVM model, string userId)
        {
            var response = await _paymentService.Withdraw(model);

            if (!response.Item1) return false;

            var senderWallet = await (await _repository.GetAsync<Wallet>())
                .FirstAsync(w => w.UserId == userId);

            if (senderWallet.Balance < model.Amount) 
                throw new InvalidOperationException("Insufficient funds");

            var transaction = new Transaction
            {
                Amount = model.Amount,
                SenderId = userId,
                ReceiverId = "",
                WalletId = senderWallet.Id,
                Reference = response.Item2,
                Status = true,
                Description = "Withdrawal",
                TransactionType = TransactionTypes.Withdrawal.ToString(),
            };

            await _repository.AddAsync(transaction);

            senderWallet.Balance -= model.Amount;
            await _repository.UpdateAsync(senderWallet);

            return true;
        }

        public async Task<bool> Transfer(string senderId, string receiverId, decimal amount)
        {
            var senderWallet = (await (await _repository.GetAsync<Wallet>())
                .FirstAsync(w => w.UserId == senderId));

            if (senderWallet.Balance < amount) 
                throw new InvalidOperationException("Insufficient funds");

            var receiverWallet = (await (await _repository.GetAsync<Wallet>())
                .FirstAsync(w => w.UserId == receiverId));
            var receiver = await _repository.GetAsync<AppUser>(receiverId);

            var transaction = new Transaction
            {
                Amount = amount,
                SenderId = senderId,
                ReceiverId = receiverId,
                WalletId = senderWallet.Id,
                Reference = TransactionHelper.GenerateTransRef(),
                Status = true,
                Description = $"Rewarding {receiver?.FirstName} {receiver?.LastName}",
                TransactionType = TransactionTypes.Transfer.ToString(),
            };

            await _repository.AddAsync(transaction);

            senderWallet.Balance -= amount;
            await _repository.UpdateAsync(senderWallet);

            receiverWallet.Balance += amount;
            await _repository.UpdateAsync(receiverWallet);

            return true;
        }
    }
}
  1. PaymentController
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Demo.Models.Entities;
using Demo.Models.Enums;
using Demo.Models.ViewModels;
using Demo.Services.Payment;
using Demo.Services.Repositories;

namespace Demo.Controllers;

[Route("payments")]
public class PaymentController : Controller
{
    private readonly IPayments _payments;
    private readonly UserManager<AppUser> _userManger;
    private readonly SignInManager<AppUser> _signInManager;
    private readonly IRepository _repository;
    public PaymentController(IPayments payments, UserManager<AppUser> userManager, SignInManager<AppUser> signInManager, IRepository repository)
    {
        _payments = payments;
        _userManger = userManager;
        _signInManager = signInManager;
        _repository = repository;
    }

    [HttpPost("withdraw")]
    public async Task<IActionResult> Withdraw([FromForm] CreateWithdrawalVM model)
    {
        try
        {
            var response = await _payments.Withdraw(model, userId);
            if (!response)
            {
                return BadRequest("Withdrawal failed");
            }
            return Ok("Withdrawal successful");
        }
        catch (InvalidOperationException e)
        {
            return BadRequest(e.Message);
        }
    }

    [HttpPost("transfer/{receiverId}")]
    public async Task<IActionResult> Transfer([FromRoute] string receiverId, [FromForm] SendRewardVM model)
    {
        var userId = User.Claims.First(c => c.Type == "id").Value;
        try
        {
            var isSuccessful = await _payments.Transfer(userId, receiverId, model.Amount);

            if (!isSuccessful)
            {
                return BadRequest("Transfer failed");
            }

            return Ok("Transfer successful");
        }
        catch (InvalidOperationException e)
        {
            return BadRequest(e.Message);
        }
    }

    [HttpGet("banks")]
    public async Task<IActionResult> GetBanks()
    {
        var banks = await _payments.GetBanks();

        return Ok(banks);
    }
}

Recommendation on how Paystack withdrawal endpoints can be greatly improved.

  1. Improve the documentation on how the payout endpoint will be implementation using .NET SDK.

  2. Provide demo/test account for endpoint testing just like the deposit endpoint.

  3. Engage developer advocates for better community engagements and feedbacks.