Search by

otatechie / laravel-paystack-connect

otatechie

Marketplace payments for Laravel on Paystack: seller onboarding, subaccounts, split payments with platform fees, and webhooks that are safe to trust.

Package info

github.com/otatechie/laravel-paystack-connect

pkg:composer/otatechie/laravel-paystack-connect

Statistics

Installs: 61

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-09-24 10:07 UTC

This package is auto-updated.

Last update: 2026-09-24 10:17:25 UTC


README

Tests Latest version License: MIT

Marketplace payments for Laravel on Paystack. Your customers pay a seller, Paystack pays the seller's share into their bank or mobile money wallet without it passing through you, and your platform keeps a fee.

Paystack's API gives you subaccounts and split payments. This package gives you everything around them that you would otherwise build by hand: seller onboarding, fee rules, local records, and webhooks you can trust.

  • Exact money. Amounts are integers in pesewas, kobo or cents. 19.99 is always 1999, never 1998.
  • Seller onboarding. Bank and mobile money lists come live from Paystack, account holders are verified where Paystack allows it, and connecting a seller twice updates their subaccount instead of creating a duplicate.
  • Platform fees. A percentage plus a flat amount, with a minimum and a maximum per currency.
  • Webhooks that are safe to trust. Signatures are checked against the raw body, every event is stored once so Paystack's retries are ignored, and a payment is only marked paid when the amount and currency match exactly.
  • Nothing fails silently. Every Paystack error throws with Paystack's own message, and webhook failures are logged and retried.

What it doesn't do: each payment goes to one seller, so a cart with several sellers needs one payment per seller (or Paystack's multi-split payments, which this package doesn't wrap yet). Payouts to sellers happen through Paystack's settlements, not this package, and disputes are only surfaced as raw WebhookReceived events. For anything else Paystack offers, PaystackConnect::client() gives you an authenticated client for its API.

How it works

  1. You connect each seller once. Their bank or mobile money account becomes a Paystack subaccount, and the package records which of your models it belongs to.
  2. At checkout you name the seller. The customer pays on Paystack's payment form. The money goes to Paystack, never to you.
  3. Paystack splits the payment itself: your platform fee to your Paystack balance, the rest to the seller's subaccount, and Paystack's own fee taken from your share or the seller's, depending on bearer. The package never holds or moves money; it tells Paystack how to split, and records what happened.
  4. Paystack pays out to you and to each seller on its settlement schedule, not the moment the customer pays. In Ghana and Nigeria that's the next working day by default (Paystack: getting your money).
  5. Your app learns the outcome from Paystack's webhook, which is the source of truth, or from verify() on your callback page, whichever arrives first. Either way the payment is settled once, and your listeners run once.

Payments without a seller, such as a donation or your own shop's orders, skip the split: the whole amount goes to your balance and no fee is taken.

Contents: How it works · Installation · Onboard a seller · Take a payment · React to payments · Refunds · Fees · Currencies · Money · Errors · Moving an existing app over · Testing your app · Trying it against Paystack's test mode · Security · Configuration · Paystack references

Requirements

PHP 8.3+ and Laravel 12 or 13. PHP 8.5 is supported on Laravel 13.

Installation

Install it, then publish the migration and config:

composer require otatechie/laravel-paystack-connect
php artisan vendor:publish --tag="paystack-connect-migrations"
php artisan migrate
php artisan vendor:publish --tag="paystack-connect-config"

Add your keys to .env:

PAYSTACK_SECRET_KEY=sk_test_xxx
PAYSTACK_PUBLIC_KEY=pk_test_xxx

# Your Paystack account's currency: GHS, NGN, KES, ZAR, XOF, EGP or RWF
PAYSTACK_CURRENCY=GHS

In your Paystack dashboard, under Settings → API Keys & Webhooks, put https://your-app.com/paystack/webhook in the Webhook URL field. Not the Callback URL field: each checkout sends its own. Test and live mode each have their own webhook URL, and Paystack must be able to reach it: a .test or localhost address won't work.

Onboard a seller

Add the trait to the model that gets paid, such as a business, vendor or school:

use Otatechie\PaystackConnect\Concerns\HasPaystackSubaccount;

class Business extends Model
{
    use HasPaystackSubaccount;
}

Show the seller Paystack's list of banks or mobile money networks, and keep the code they pick:

use Otatechie\PaystackConnect\Facades\PaystackConnect;

PaystackConnect::banks()->list('ghana');          // banks
PaystackConnect::banks()->mobileMoney('ghana');   // MTN, Telecel, AirtelTigo

Countries use Paystack's names: ghana, nigeria, kenya, south africa, côte d'ivoire, egypt and rwanda.

Then connect their account:

use Otatechie\PaystackConnect\Support\SettlementAccount;

$business->connectPaystackAccount(
    SettlementAccount::mobileMoney('Kofi Prints', 'MTN', '0241234567', 'GHS')
        ->withContact(email: $owner->email, name: $owner->name),
);

$business->canReceivePaystackPayments(); // true

In Ghana and Nigeria, the account holder's name is checked with Paystack first (Paystack only offers this lookup there). If it can't be resolved, a PaystackException explains why and nothing is created. Paystack has no such lookup in other countries, so there it checks the account itself when the subaccount is created. To skip the lookup, set sellers.verify_accounts to false.

An onboarding page

Paystack has no hosted onboarding, so sellers connect their account on a page in your app. The package ships no views; here's a minimal one to copy and adapt, whether you use Blade, Inertia or Livewire.

use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Otatechie\PaystackConnect\Banks;
use Otatechie\PaystackConnect\Exceptions\PaystackException;
use Otatechie\PaystackConnect\Facades\PaystackConnect;
use Otatechie\PaystackConnect\Support\SettlementAccount;

class PayoutAccountController
{
    // Your Paystack account's currency, and Paystack's name for its country:
    // GHS → "ghana", NGN → "nigeria", KES → "kenya", and so on.
    private function currency(): string
    {
        return strtoupper(config('paystack-connect.currency'));
    }

    private function country(): string
    {
        return Banks::COUNTRIES[$this->currency()];
    }

    public function edit()
    {
        return view('payout-account', [
            'banks' => PaystackConnect::banks()->list($this->country())->sortBy('name'),
        ]);
    }

    public function update(Request $request)
    {
        $data = $request->validate([
            'bank_code' => ['required', 'string'],
            'account_number' => ['required', 'string'],
        ]);

        $bank = PaystackConnect::banks()->find($this->country(), $data['bank_code'])
            ?? throw ValidationException::withMessages(['bank_code' => 'Pick a bank or network from the list.']);

        $business = $request->user()->business;

        $account = $bank['type'] === 'mobile_money'
            ? SettlementAccount::mobileMoney($business->name, $bank['code'], $data['account_number'], $this->currency(), $bank['name'])
            : SettlementAccount::bank($business->name, $bank['code'], $data['account_number'], $this->currency(), $bank['name']);

        try {
            $business->connectPaystackAccount($account->withContact(email: $request->user()->email));
        } catch (PaystackException $e) {
            // For example "Could not resolve account name" for a mistyped number.
            return back()->withInput()->withErrors(['account_number' => $e->getMessage()]);
        }

        return back()->with('status', 'Payout account connected.');
    }
}

The view needs a <select name="bank_code"> of $banks (value code, label name), an account_number input, and a submit button.

If your sellers are in several countries, let them pick a country first and use it in place of country().

Submitting the form again updates the same subaccount.

Other seller helpers

PaystackConnect::subaccounts()->for($business);             // the Subaccount, or null
PaystackConnect::banks()->find('ghana', 'MTN');             // one bank or network, or null
PaystackConnect::banks()->resolve('0241234567', 'MTN');     // the account holder's name (Ghana and Nigeria)

The subaccount record

$business->paystackSubaccount is a Subaccount model, stored in paystack_subaccounts.

Field or method Meaning
subaccount_code Paystack's code for the subaccount, ACCT_....
business_name, bank_name, account_name What was connected, and the holder's name where Paystack looks it up.
maskedAccountNumber() "•••• 4567", for display. The full number is encrypted at rest and never included in JSON.
active Whether Paystack will settle to it. canReceivePaystackPayments() checks this.
owner, payments() The seller model, and every payment made to them.

Take a payment

$payment = PaystackConnect::checkout()
    ->amount('250.00', 'GHS')
    ->email($client->email)
    ->seller($business)            // settles to the seller, minus your fee
    ->for($invoice)                // optional: what is being paid for
    ->callbackUrl(route('invoices.paid', $invoice))
    ->create();

return redirect($payment->authorization_url);

The fee comes from your config and is sent to Paystack as the transaction's transaction_charge, a flat amount that goes to your account whatever the subaccount's percentage says (split payments). To override it for one payment, use ->fee('10.00'); to make the seller pay Paystack's fee for one payment, use ->bearer('subaccount') (see Fees).

Other options:

->channels(['card', 'mobile_money'])   // limit how the customer can pay
->metadata(['order_id' => $order->id]) // sent to Paystack, shown in its dashboard
->reference('INV-2026-0042')           // your own reference; must be unique

Channels Paystack accepts: card, bank, apple_pay, ussd, qr, mobile_money, bank_transfer, eft, capitec_pay and payattitude; which ones the customer sees depends on their country. A reference may contain only letters, digits, -, ., = and _ (Transaction API; _ isn't listed there, but Paystack accepts it).

Without ->seller(), the whole amount goes to your own Paystack balance and no fee is taken.

Redirect or popup

The customer pays on Paystack's own payment form. Every checkout gives you two ways to show it, for the same transaction:

  • Redirect with $payment->authorization_url: the customer leaves your site for Paystack's page, pays, and comes back to your callbackUrl. No JavaScript needed. This is what the example above does.
  • Popup with $payment->access_code: Paystack's JavaScript opens the form on top of your page, and the customer never leaves. Smoother, but it needs a little JavaScript.

With Inertia, a plain redirect() to Paystack fails with a CORS error, because the browser won't follow an XHR redirect to another site. Use a full page visit instead, or the popup:

return Inertia::location($payment->authorization_url);

For the popup, return the access code instead of redirecting, and verify the payment on your server when the popup reports success:

// routes/web.php
Route::post('/pay', [PaymentController::class, 'store']);
Route::get('/pay/verify/{reference}', [PaymentController::class, 'verify']);

// PaymentController: start the payment and hand back the access code.
public function store(Request $request)
{
    $data = $request->validate(['amount' => ['required', 'numeric', 'min:1'], 'email' => ['required', 'email']]);

    $payment = PaystackConnect::checkout()->amount((string) $data['amount'])->email($data['email'])->create();

    return response()->json(['access_code' => $payment->access_code, 'reference' => $payment->reference]);
}

// PaymentController: the page calls this once the popup reports success.
public function verify(string $reference)
{
    return response()->json(['paid' => (bool) PaystackConnect::verify($reference)?->isSuccessful()]);
}

In the page, post your form to /pay with fetch(), then open the popup with the code it returns:

// <script src="https://js.paystack.co/v2/inline.js"></script>
new PaystackPop().resumeTransaction(access_code, {
    onSuccess: () => fetch(`/pay/verify/${reference}`), // then show the result
    onCancel: () => { /* the customer closed the popup */ },
});

Never treat the popup's success as final: it runs in the customer's browser, which can be tampered with. Only your server's verify() (or the webhook) decides whether the payment went through. For the exact options of Paystack's script, see accept payments.

Linking a payment to what it's for

->for($invoice) records what a payment is for, so from the payment you can always find the invoice: $payment->payable. That's handy in a listener ("this payment succeeded; which invoice was it?").

Your app usually needs the other direction: starting from the invoice, has it been paid? Add HasPaystackPayments to the model you pass to ->for():

use Otatechie\PaystackConnect\Concerns\HasPaystackPayments;

class Invoice extends Model
{
    use HasPaystackPayments;
}

$invoice->paystackPayments;          // every attempt to pay it
$invoice->latestPaystackPayment();   // the most recent one, or null
$invoice->isPaidOnPaystack();        // a payment succeeded (and wasn't fully refunded)

One invoice can have several payments, because each try is its own payment: a customer might give up, then come back and pay. isPaidOnPaystack() looks for a success among all of them. A payment that isn't for a record of yours, such as a donation, doesn't need ->for() or the trait.

The payment record

create() returns a Payment model, stored in paystack_payments. Amounts are in minor units; the helpers give you Money objects.

Field or method Meaning
reference Sent to Paystack. Generated as pc_... unless you set one.
status pending, success, failed, amount_mismatch or refunded (a PaymentStatus enum).
total(), platformFee(), sellerShare() What the customer paid, your fee, and the seller's share before Paystack's own fee.
paystack_fee Paystack's fee, once the payment has succeeded.
channel, paid_at How and when the customer paid.
failure_reason Paystack's reason when a payment failed.
isPending(), isSuccessful(), isRefunded() Status checks.
payable, subaccount The model being paid for, and the seller's subaccount.
paystack_data Paystack's full transaction data, for anything else you need.

access_code and paystack_data are left out of the model's JSON: one opens the checkout, the other holds card and customer details.

On your callback page, confirm the payment straight away. verify() returns null when no payment has that reference:

$payment = PaystackConnect::verify($request->query('reference'));

if ($payment?->isSuccessful()) {
    return redirect()->route('invoices.show', $payment->payable)->with('status', 'Paid, thank you.');
}

return redirect()->route('invoices.index')->with('error', 'The payment did not go through.');

The webhook is still the source of truth. Verifying and the webhook both update the payment, and it only ever settles once.

React to payments

use Otatechie\PaystackConnect\Events\PaymentSucceeded;

Event::listen(function (PaymentSucceeded $event) {
    $invoice = $event->payment->payable;

    $invoice->markPaid();
    Mail::to($invoice->client)->queue(new ReceiptMail($invoice));
});
Event When
PaymentSucceeded Paystack confirmed the charge and the amount and currency match.
PaymentFailed The charge was declined. The customer can still pay on the same checkout, so PaymentSucceeded may follow.
PaymentAmountMismatch Paystack charged a different amount or currency. The payment is not marked paid; review it.
PaymentRefunded Paystack processed a refund. $event->amount is how much went back.
SubaccountConnected A seller's subaccount was created or updated.
WebhookReceived Any verified webhook, including events this package doesn't handle itself. Fires before the webhook is marked handled.
WebhookHandled A webhook was handled and the payment saved. Listen here when you need the payment's new state.

To handle an event the package doesn't, such as a dispute, listen for WebhookReceived. It carries the event name and Paystack's full payload:

use Otatechie\PaystackConnect\Events\WebhookReceived;

Event::listen(function (WebhookReceived $event) {
    if ($event->event === 'charge.dispute.create') {
        // $event->payload['data'] ...
    }
});

A checkout the customer hasn't paid yet stays pending, even though Paystack reports it as "abandoned" (verify payments): they can still come back and pay. To clean up old unpaid checkouts, query pending payments older than you care about.

Listeners run once per payment, even when Paystack retries a webhook or two deliveries overlap. If a listener throws, the webhook returns an error, the event is kept, and it's processed again by Paystack's next retry or by paystack-connect:retry-webhooks (below), whichever comes first. In live mode Paystack retries every 3 minutes for the first 4 tries, then hourly for 72 hours; in test mode, hourly for 10 hours. You can also resend events from the Paystack dashboard (webhooks).

Paystack gives each delivery 30 seconds, so keep listeners quick and queue slow work such as emails, as above.

Keeping webhooks healthy

Add these to your scheduler (routes/console.php):

use Illuminate\Support\Facades\Schedule;
use Otatechie\PaystackConnect\Models\WebhookEvent;

// Process again any webhook that failed, without waiting for Paystack.
Schedule::command('paystack-connect:retry-webhooks')->hourly();

// Remove processed webhooks older than webhook.keep_days (30 by default).
Schedule::command('model:prune', ['--model' => WebhookEvent::class])->daily();

Failed webhooks are never pruned, so they can always be retried. You can also run php artisan paystack-connect:retry-webhooks by hand; it lists anything still failing.

Refunds

PaystackConnect::refund($payment);                                  // everything
PaystackConnect::refund($payment, Money::major('50.00', 'GHS'));    // part of it

Paystack processes refunds in the background, which can take a while. Until it does, the amount is held as pending (per refund, by Paystack's refund id), so the same money can't be refunded twice. When the refund.processed webhook arrives, the payment's refunded_amount goes up and PaymentRefunded is dispatched. Refunds made from the Paystack dashboard are recorded the same way. Once the whole amount is back, the status becomes refunded. If Paystack fails the refund (refund.failed), the amount can be refunded again. If Paystack needs the customer's bank details first (refund.needs-attention), the refund stays pending until you provide them through Paystack's retry endpoint or dashboard (refunds).

$payment->pendingRefundAmount(); // GHS 50.00 until Paystack processes it
$payment->refundedAmount();      // GHS 50.00 after
$payment->refundableAmount();    // GHS 200.00: not refunded and not pending
$payment->isRefunded();          // false until everything is back

Fees

'fees' => [
    'default' => ['percentage' => 2.5, 'flat' => 0, 'min' => null, 'max' => null],
    'currencies' => [
        'NGN' => ['min' => 250, 'max' => 5000],
        'KES' => ['percentage' => 3],
        'ZAR' => ['percentage' => 3.5, 'flat' => 1.50],
    ],
],

A fee is a percentage plus a flat amount, kept between a minimum and a maximum. Amounts are in major units (NGN 250, not 25,000 kobo), and a currency without its own rule uses the default. Payments without a seller have no fee. To preview one:

PaystackConnect::feeFor(Money::major('10.00', 'GHS')); // GHS 0.25; the seller gets GHS 9.75

Your fee must cover Paystack's. By default your platform pays Paystack's own fee out of your fee (bearer set to account), so a fee below Paystack's loses you money on every payment. The defaults above are set to cover Paystack's published rates: 1.95% in Ghana; 1.5% + NGN 100 in Nigeria (the NGN 100 only over NGN 2,500, capped at NGN 2,000); up to 2.9% in Kenya (cards); 2.9% + R1 + VAT in South Africa (pricing). Paystack hasn't published rates for Côte d'Ivoire, Egypt and Rwanda, so check yours there. Alternatively, set bearer to subaccount to have sellers pay Paystack's fee instead.

The fee is never more than the payment, so a minimum above a small payment takes all of it: with a NGN 250 minimum, a NGN 200 payment leaves the seller nothing.

Currencies

Each Paystack account charges in its own country's currency, plus USD in some countries if Paystack has enabled it for you. Minimums and the XOF rule below are from Paystack's supported currency table; Egypt and Rwanda aren't in that table yet, though Paystack's API lists them. Anything else is refused with "Currency not supported by merchant".

Country Currency Paystack's minimum Account holder lookup
Ghana GHS GHS 0.10 yes
Nigeria NGN NGN 50.00 yes
Kenya KES KES 3.00 no
South Africa ZAR ZAR 1.00 no
Côte d'Ivoire XOF XOF 1 no
Egypt EGP not published no
Rwanda RWF not published no

USD has a minimum of USD 2.00; Paystack documents it for Kenya and Nigeria. XOF and RWF have no subunit, so amounts must be whole: Money::major('10.50', 'XOF') throws instead of Paystack silently charging XOF 10. Fees in these currencies are rounded to whole units.

Money

Amounts are Money objects: an integer in minor units plus a currency, so 19.99 is always 1999 and never a float.

use Otatechie\PaystackConnect\Support\Money;

$price = Money::major('19.99', 'GHS');   // from a major-unit string (or int or float)
$price = Money::minor(1999, 'GHS');      // from minor units, as Paystack sends them

$price->minor;                          // 1999
$price->toMajorString();                // "19.99"
(string) $price;                        // "GHS 19.99"
$price->add(Money::major('5.00', 'GHS')); // GHS 24.99; mixing currencies throws
json_encode($price);                    // {"amount":1999,"currency":"GHS","formatted":"GHS 19.99"}

Invalid amounts throw InvalidAmount: negative, unparseable ("19.999", "1,000"), mixed currencies, or a fraction of XOF or RWF.

Errors

Everything Paystack refuses throws PaystackException with Paystack's own message, and $e->body holds its full response:

use Otatechie\PaystackConnect\Exceptions\PaystackException;

try {
    $payment = PaystackConnect::checkout()->amount('50')->email($email)->create();
} catch (PaystackException $e) {
    $e->getMessage();   // "Paystack POST /transaction/initialize failed (403): Currency not supported by merchant"
    $e->getCode();      // 403, or 0 when Paystack couldn't be reached
    $e->body;           // Paystack's JSON response, or null
}

A missing secret key and network failures throw it too. Mistakes in your own calls, such as a checkout with no email or a seller with no subaccount, throw InvalidArgumentException before anything is sent.

Moving an existing app over

If your sellers already have subaccounts, copy them into the local table:

php artisan paystack-connect:import-subaccounts

Subaccounts created by this package carry their owner in Paystack's metadata, so they are linked to the right model. Older ones are imported without an owner; link each to its seller, which also records the owner on Paystack:

PaystackConnect::subaccounts()->attach($business, 'ACCT_8f4s1eq7ml6rlzj');

To see bank and network codes:

php artisan paystack-connect:banks ghana --type=mobile_money

Testing your app

PaystackConnect::fake() replaces Paystack for the rest of the test. Checkouts, subaccounts, bank lists, account checks and refunds all get realistic responses, and nothing leaves your machine.

use Otatechie\PaystackConnect\Facades\PaystackConnect;

it('marks the invoice paid', function () {
    $paystack = PaystackConnect::fake();

    $this->post(route('invoices.pay', $invoice))->assertRedirect();

    $paystack->assertCheckoutCreated(fn ($data) => $data['amount'] === 25000);

    $paystack->pay(Payment::first());   // as if charge.success arrived; your listeners run

    expect($invoice->refresh()->paid)->toBeTrue();
});
Method What it does
pay($payment) Settles the payment as paid. verify() reports it as paid from then on; before that, it reports "abandoned" like Paystack does, and the payment stays pending.
fail($payment, $reason) Settles the payment as failed.
refunded($payment, ?Money) Records a refund, as the refund.processed webhook would.
assertCheckoutCreated(?callable) A checkout was started. The callback receives what was sent to Paystack.
assertSubaccountCreated(?callable) A seller's subaccount was created.
assertNothingSent() Nothing was sent to Paystack.
requests() Everything sent to Paystack, for assertions of your own.

The fake throws on any other endpoint. For those, use Http::fake().

Trying it against Paystack's test mode

  • Pay with Paystack's "no validation" test card: 4084 0840 8408 4081, CVV 408, any future expiry. Other cards and channels are on Paystack's test payments page.
  • Connecting a seller needs a real account or wallet number, even in test mode. No money moves. Paystack allows only 3 lookups of real accounts a day in test mode, and its test bank code 001 works for lookups but not for creating a subaccount. Neither is in Paystack's docs; both come from its API's own error messages.
  • Webhooks need a public URL, so on your machine use a tunnel such as herd share, expose or ngrok (see Installation).
  • Test refunds can stay pending for a while before Paystack processes them.

Security

Every webhook's signature is checked against the raw request body. To also accept webhooks only from Paystack's servers, uncomment their IP addresses under webhook.allowed_ips in the config (the three IPs Paystack publishes on its webhooks page). If your app sits behind a proxy or load balancer, set up Laravel's trusted proxies first, or every webhook will be rejected.

To add middleware in front of the webhook, such as a throttle, list it under webhook.middleware. To register the route yourself, set webhook.enabled to false and point your route at WebhookController, keeping the VerifyPaystackSignature middleware.

Sellers' account numbers are encrypted in the database and left out of the model's JSON. Only the last four digits are stored in the clear, for display.

To report a vulnerability, see SECURITY.md. Please don't open a public issue.

Configuration

Publish config/paystack-connect.php to change any of these. Each setting is explained in the file.

Setting Default What it does
secret_key, public_key .env Your Paystack keys. The public key is only for your own frontend.
currency GHS Currency for amounts given without one.
fees 2.5%, per-currency min/max Your platform fee on payments to sellers.
bearer account Who pays Paystack's fee: account (you) or subaccount (the seller).
sellers.verify_accounts true Look up the account holder before creating a subaccount (Ghana and Nigeria).
sellers.percentage_charge 0 Your share on payments made outside the package, such as Paystack payment pages.
webhook.enabled true Register the webhook route.
webhook.path paystack/webhook The webhook URL path.
webhook.middleware [] Extra middleware in front of the signature check.
webhook.keep_days 30 Days to keep processed webhook events; null keeps them forever.
webhook.allowed_ips none Only accept webhooks from these IPs.
banks_cache_ttl 24 hours How long Paystack's bank list is cached, in seconds.
log_channel default channel Where webhook problems are logged.
base_url, timeout Paystack's API, 15 s For proxies and slow networks.

Paystack references

The package's behaviour follows these pages of Paystack's documentation:

Two behaviours come from Paystack's API rather than its docs: the test-mode limit on account lookups, and the requested_amount field that lets payments settle when you pass Paystack's fee on to the customer.

Contributing

composer test
composer analyse
composer format

License

MIT. See LICENSE.md.