Add Paystack, Flutterwave, and Hubtel for encounter billing.
Deploy Ladill Care / deploy (push) Successful in 1m0s

Clinics on Pro/Enterprise can connect their own gateway in Integrations and collect card or MoMo on bills with 0% Ladill fee, while cash payments and balance totals stay correct for pending checkouts.
This commit is contained in:
isaacclad
2026-07-16 10:24:28 +00:00
parent 068c5aa63f
commit 7a0a7fcb88
19 changed files with 1283 additions and 31 deletions
+3 -1
View File
@@ -58,12 +58,14 @@ class BillController extends Controller
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$manualMethods = array_keys(collect(config('care.payment_methods', []))->except(['gateway'])->all());
$payment = $this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
'method' => ['required', 'string', 'in:'.implode(',', $manualMethods)],
'reference' => ['nullable', 'string', 'max:100'],
]),
$this->ownerRef($request),
+78 -1
View File
@@ -8,9 +8,11 @@ use App\Models\Bill;
use App\Models\Visit;
use App\Services\Care\BillService;
use App\Services\Care\OrganizationResolver;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class BillController extends Controller
{
@@ -18,6 +20,7 @@ class BillController extends Controller
public function __construct(
protected BillService $bills,
protected MerchantGatewayService $gateway,
) {}
public function index(Request $request): View
@@ -62,14 +65,26 @@ class BillController extends Controller
->can($this->member($request), 'bills.manage');
$canPay = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'payments.manage');
$organization = $this->organization($request);
$gatewayConfigured = $this->gateway->isConfigured($organization);
$gateway = $this->gateway->settingFor($organization);
// Manual record form excludes gateway method (online checkout is separate).
$manualMethods = collect(config('care.payment_methods', []))
->except(['gateway'])
->all();
return view('care.bills.show', [
'bill' => $bill,
'statuses' => config('care.bill_statuses'),
'lineTypes' => config('care.bill_line_types'),
'paymentMethods' => config('care.payment_methods'),
'manualPaymentMethods' => $manualMethods,
'canManage' => $canManage,
'canPay' => $canPay,
'gatewayConfigured' => $gatewayConfigured,
'gatewayProvider' => $gateway?->provider,
'gatewayLabels' => config('care.payment_gateways'),
]);
}
@@ -119,12 +134,14 @@ class BillController extends Controller
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$manualMethods = array_keys(collect(config('care.payment_methods', []))->except(['gateway'])->all());
$this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
'method' => ['required', 'string', 'in:'.implode(',', $manualMethods)],
'reference' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string', 'max:500'],
]),
@@ -134,6 +151,66 @@ class BillController extends Controller
return back()->with('success', 'Payment recorded.');
}
public function startGatewayPayment(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$validated = $request->validate([
'amount_minor' => ['nullable', 'integer', 'min:1'],
]);
$amount = (int) ($validated['amount_minor'] ?? $bill->balance_minor);
try {
$result = $this->bills->startGatewayPayment(
$bill->loadMissing('patient'),
$this->organization($request),
$this->ownerRef($request),
$amount,
$request->user(),
$this->ownerRef($request),
);
} catch (RuntimeException|\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()->away($result['checkout_url']);
}
public function paymentCallback(Request $request): RedirectResponse|View
{
$reference = trim((string) (
$request->query('reference')
?: $request->query('tx_ref')
?: $request->query('clientReference')
?: ''
));
if ($reference === '') {
return view('care.bills.payment-return', [
'redirect' => route('care.bills.index'),
]);
}
try {
$payment = $this->bills->completeGatewayPayment($reference);
} catch (\Throwable) {
session()->flash('error', 'Payment could not be verified. If the customer was charged, re-check the bill or contact support with reference '.$reference.'.');
return view('care.bills.payment-return', [
'redirect' => route('care.bills.index'),
]);
}
$bill = $payment->bill;
session()->flash('success', 'Online payment recorded for '.$bill->invoice_number.'.');
return view('care.bills.payment-return', [
'redirect' => route('care.bills.show', $bill),
]);
}
public function void(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
@@ -4,21 +4,29 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\PaymentGatewaySetting;
use App\Services\Care\AuditLogger;
use App\Services\Care\CarePermissions;
use App\Services\Care\PlanService;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class IntegrationsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request, MessagingCredentialsService $credentials): View
{
public function edit(
Request $request,
MessagingCredentialsService $credentials,
MerchantGatewayService $gateway,
PlanService $plans,
): View {
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage');
@@ -27,6 +35,9 @@ class IntegrationsController extends Controller
'organization' => $organization,
'canManage' => $canManage,
'credential' => $credentials->forOrganization($organization),
'gateway' => $gateway->settingFor($organization),
'canUsePaymentGateway' => $plans->canUsePaymentGateway($organization),
'gatewayLabels' => config('care.payment_gateways'),
]);
}
@@ -149,4 +160,93 @@ class IntegrationsController extends Controller
return response()->json($result['data'] ?? []);
}
public function saveGateway(Request $request, PlanService $plans): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
if (! $plans->canUsePaymentGateway($organization)) {
return back()->with('error', 'Connecting your own payment gateway requires Care Pro or Enterprise.');
}
$data = $request->validate([
'gateway_provider' => ['required', 'string', Rule::in(PaymentGatewaySetting::providers())],
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
'gateway_is_active' => ['nullable', 'boolean'],
]);
$existing = PaymentGatewaySetting::query()
->where('organization_id', $organization->id)
->first();
$publicKey = trim((string) ($data['gateway_public_key'] ?? ''));
$secretKey = trim((string) ($data['gateway_secret_key'] ?? ''));
$webhookSecret = trim((string) ($data['gateway_webhook_secret'] ?? ''));
$attributes = [
'owner_ref' => $this->ownerRef($request),
'provider' => $data['gateway_provider'],
'is_active' => $request->boolean('gateway_is_active', true),
];
// Blank fields keep previously stored encrypted credentials.
if ($publicKey !== '') {
$attributes['public_key'] = $publicKey;
}
if ($secretKey !== '') {
$attributes['secret_key'] = $secretKey;
}
if ($webhookSecret !== '') {
$attributes['webhook_secret'] = $webhookSecret;
}
if ($existing === null && $secretKey === '') {
return back()->withInput()->with('error', 'Secret / API key is required to connect a payment gateway.');
}
if ($data['gateway_provider'] === PaymentGatewaySetting::PROVIDER_HUBTEL && $publicKey === '' && ! $existing?->public_key) {
return back()->withInput()->with('error', 'Hubtel requires a merchant account number (public key field).');
}
PaymentGatewaySetting::query()->updateOrCreate(
['organization_id' => $organization->id],
$attributes,
);
AuditLogger::record(
$this->ownerRef($request),
'integrations.gateway_connected',
$organization->id,
$this->ownerRef($request),
\App\Models\Organization::class,
$organization->id,
['provider' => $data['gateway_provider']],
);
return back()->with('success', 'Payment gateway saved.');
}
public function disconnectGateway(Request $request, PlanService $plans): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
PaymentGatewaySetting::query()
->where('organization_id', $organization->id)
->delete();
AuditLogger::record(
$this->ownerRef($request),
'integrations.gateway_disconnected',
$organization->id,
$this->ownerRef($request),
\App\Models\Organization::class,
$organization->id,
);
return back()->with('success', 'Payment gateway disconnected.');
}
}
+30 -1
View File
@@ -11,11 +11,27 @@ class Payment extends Model
{
use BelongsToOwner;
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_FAILED = 'failed';
public const METHOD_CASH = 'cash';
public const METHOD_MOMO = 'momo';
public const METHOD_CARD = 'card';
public const METHOD_OTHER = 'other';
public const METHOD_GATEWAY = 'gateway';
protected $table = 'care_payments';
protected $fillable = [
'uuid', 'owner_ref', 'bill_id', 'amount_minor', 'method',
'reference', 'paid_at', 'recorded_by', 'notes',
'status', 'gateway_provider', 'reference', 'paid_at', 'recorded_by', 'notes',
];
protected function casts(): array
@@ -29,6 +45,9 @@ class Payment extends Model
if (! $payment->uuid) {
$payment->uuid = (string) Str::uuid();
}
if (! $payment->status) {
$payment->status = self::STATUS_PAID;
}
});
}
@@ -41,4 +60,14 @@ class Payment extends Model
{
return $this->belongsTo(Bill::class, 'bill_id');
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentGatewaySetting extends Model
{
use BelongsToOwner;
public const PROVIDER_PAYSTACK = 'paystack';
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
public const PROVIDER_HUBTEL = 'hubtel';
protected $table = 'care_payment_gateway_settings';
protected $fillable = [
'owner_ref',
'organization_id',
'provider',
'public_key',
'secret_key',
'webhook_secret',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'public_key' => 'encrypted',
'secret_key' => 'encrypted',
'webhook_secret' => 'encrypted',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function isConfigured(): bool
{
if (! $this->is_active) {
return false;
}
$secret = trim((string) $this->secret_key);
return $secret !== '' && in_array($this->provider, [
self::PROVIDER_PAYSTACK,
self::PROVIDER_FLUTTERWAVE,
self::PROVIDER_HUBTEL,
], true);
}
/** @return list<string> */
public static function providers(): array
{
return [
self::PROVIDER_PAYSTACK,
self::PROVIDER_FLUTTERWAVE,
self::PROVIDER_HUBTEL,
];
}
}
+191 -20
View File
@@ -8,15 +8,20 @@ use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Payment;
use App\Models\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
class BillService
{
public function __construct(
protected InvoiceNumberGenerator $invoices,
protected MerchantGatewayService $gateway,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -224,31 +229,15 @@ class BillService
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amount,
'method' => $data['method'] ?? 'cash',
'method' => $data['method'] ?? Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID,
'reference' => $data['reference'] ?? null,
'paid_at' => $data['paid_at'] ?? now(),
'recorded_by' => $actorRef,
'notes' => $data['notes'] ?? null,
]);
$bill->refresh();
$paid = (int) $bill->payments()->sum('amount_minor');
$balance = max(0, $bill->total_minor - $paid);
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
$status = Bill::STATUS_PARTIAL;
} elseif ($balance === 0 && $bill->total_minor > 0) {
$status = Bill::STATUS_PAID;
} elseif ($paid >= $bill->total_minor) {
$status = Bill::STATUS_PAID;
}
$bill->update([
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
$this->syncPaymentTotals($bill);
AuditLogger::record($ownerRef, 'payment.recorded', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
'bill_id' => $bill->id,
@@ -258,6 +247,155 @@ class BillService
return $payment;
}
/**
* Start an online checkout (Paystack / Flutterwave / Hubtel) against the bill balance.
*
* @return array{payment: Payment, checkout_url: string, reference: string, provider: string}
*/
public function startGatewayPayment(
Bill $bill,
Organization $organization,
string $ownerRef,
int $amountMinor,
?User $actor = null,
?string $actorRef = null,
): array {
if ($bill->status === Bill::STATUS_VOID) {
throw new InvalidArgumentException('Cannot pay a void bill.');
}
if ($bill->balance_minor <= 0) {
throw new InvalidArgumentException('This bill is already fully paid.');
}
$amount = max(1, min($amountMinor, (int) $bill->balance_minor));
$email = trim((string) ($bill->patient?->email ?? ''));
if ($email === '' || ! str_contains($email, '@')) {
$email = trim((string) ($actor?->email ?? ''));
}
if ($email === '' || ! str_contains($email, '@')) {
$email = 'care+'.($organization->id).'@checkout.ladill.local';
}
$reference = 'CARE-'.strtoupper(Str::random(16));
$setting = $this->gateway->settingFor($organization);
$payment = Payment::create([
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amount,
'method' => Payment::METHOD_GATEWAY,
'status' => Payment::STATUS_PENDING,
'gateway_provider' => $setting?->provider,
'reference' => $reference,
'paid_at' => now(), // overwritten when verified; pending rows are excluded from totals
'recorded_by' => $actorRef,
'notes' => 'Online checkout initiated',
]);
try {
$checkout = $this->gateway->initializeCheckout(
$organization,
$amount,
(string) config('care.billing.currency', 'GHS'),
$email,
route('care.bills.payments.callback'),
$reference,
[
'title' => 'Invoice '.$bill->invoice_number,
'bill_id' => $bill->id,
'bill_uuid' => $bill->uuid,
'payment_id' => $payment->id,
'organization_id' => $organization->id,
'channel' => 'care_encounter',
],
);
} catch (RuntimeException $e) {
$payment->update(['status' => Payment::STATUS_FAILED, 'notes' => $e->getMessage()]);
throw $e;
}
$payment->update([
'reference' => $checkout['reference'],
'gateway_provider' => $checkout['provider'],
]);
AuditLogger::record($ownerRef, 'payment.checkout_started', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
'bill_id' => $bill->id,
'amount_minor' => $amount,
'provider' => $checkout['provider'],
'reference' => $checkout['reference'],
]);
return [
'payment' => $payment->fresh(),
'checkout_url' => $checkout['checkout_url'],
'reference' => $checkout['reference'],
'provider' => $checkout['provider'],
];
}
public function completeGatewayPayment(string $reference): Payment
{
$existingPaid = Payment::query()
->where('reference', $reference)
->where('status', Payment::STATUS_PAID)
->where('method', Payment::METHOD_GATEWAY)
->with(['bill'])
->first();
if ($existingPaid) {
return $existingPaid;
}
$payment = Payment::query()
->where('reference', $reference)
->where('status', Payment::STATUS_PENDING)
->where('method', Payment::METHOD_GATEWAY)
->with(['bill.organization', 'bill.patient'])
->firstOrFail();
$bill = $payment->bill;
$organization = $bill->organization ?? Organization::query()->findOrFail($bill->organization_id);
$result = $this->gateway->verify($organization, $reference);
if (! $result['paid']) {
$payment->update([
'status' => Payment::STATUS_FAILED,
'notes' => 'Gateway reported payment incomplete.',
]);
throw new RuntimeException('Payment was not completed.');
}
$verifiedAmount = (int) ($result['amount_minor'] ?: $payment->amount_minor);
if ($verifiedAmount <= 0) {
$verifiedAmount = (int) $payment->amount_minor;
}
$payment->update([
'status' => Payment::STATUS_PAID,
'amount_minor' => $verifiedAmount,
'paid_at' => now(),
'gateway_provider' => $result['provider'] ?? $payment->gateway_provider,
'notes' => 'Online payment verified',
]);
$this->syncPaymentTotals($bill);
AuditLogger::record($payment->owner_ref, 'payment.recorded', $bill->organization_id, $payment->recorded_by, Payment::class, $payment->id, [
'bill_id' => $bill->id,
'amount_minor' => $verifiedAmount,
'provider' => $payment->gateway_provider,
'reference' => $reference,
'channel' => 'gateway',
]);
return $payment->fresh(['bill']);
}
public function void(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if ($bill->status === Bill::STATUS_PAID) {
@@ -275,7 +413,7 @@ class BillService
$bill->refresh();
$subtotal = (int) $bill->lineItems()->sum('total_minor');
$total = max(0, $subtotal - $bill->discount_minor + $bill->tax_minor);
$paid = (int) $bill->payments()->sum('amount_minor');
$paid = $this->paidSum($bill);
$balance = max(0, $total - $paid);
$status = $bill->status;
@@ -298,6 +436,39 @@ class BillService
]);
}
protected function syncPaymentTotals(Bill $bill): void
{
$bill->refresh();
$paid = $this->paidSum($bill);
$balance = max(0, $bill->total_minor - $paid);
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
$status = Bill::STATUS_PARTIAL;
} elseif ($balance === 0 && $bill->total_minor > 0) {
$status = Bill::STATUS_PAID;
} elseif ($paid >= $bill->total_minor) {
$status = Bill::STATUS_PAID;
}
if ($bill->status === Bill::STATUS_VOID) {
$status = Bill::STATUS_VOID;
}
$bill->update([
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
}
protected function paidSum(Bill $bill): int
{
return (int) $bill->payments()
->where('status', Payment::STATUS_PAID)
->sum('amount_minor');
}
protected function assertEditable(Bill $bill): void
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
+6
View File
@@ -53,6 +53,12 @@ class PlanService
return $this->hasPaidPlan($organization);
}
/** Own payment gateway (Paystack / Flutterwave / Hubtel) for encounter billing requires Pro or Enterprise. */
public function canUsePaymentGateway(Organization $organization): bool
{
return $this->hasPaidPlan($organization) && $this->hasFeature($organization, 'billing');
}
public function hasFeature(Organization $organization, string $feature): bool
{
$features = config('care.plans.'.$this->planKey($organization).'.features', []);
+2
View File
@@ -49,6 +49,7 @@ class ReportService
->count();
$revenueToday = Payment::owned($ownerRef)
->where('status', Payment::STATUS_PAID)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
@@ -162,6 +163,7 @@ class ReportService
->where('status', '!=', Bill::STATUS_VOID);
$payments = Payment::owned($ownerRef)
->where('status', Payment::STATUS_PAID)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
@@ -0,0 +1,306 @@
<?php
namespace App\Services\Payments;
use App\Models\Organization;
use App\Models\PaymentGatewaySetting;
use App\Services\Care\PlanService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function __construct(
protected PlanService $plans,
) {}
public function settingFor(Organization $organization): ?PaymentGatewaySetting
{
return PaymentGatewaySetting::query()
->where('organization_id', $organization->id)
->first();
}
public function isConfigured(Organization $organization): bool
{
if (! $this->organizationMayUseGateway($organization)) {
return false;
}
return (bool) $this->settingFor($organization)?->isConfigured();
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
public function initializeCheckout(
Organization $organization,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata = [],
): array {
$setting = $this->requireConfigured($organization);
$currency = strtoupper($currency ?: 'GHS');
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
public function verify(Organization $organization, string $reference): array
{
$setting = $this->requireConfigured($organization);
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
protected function requireConfigured(Organization $organization): PaymentGatewaySetting
{
if (! $this->organizationMayUseGateway($organization)) {
throw new RuntimeException('Connecting your own payment gateway requires Care Pro or Enterprise.');
}
$setting = $this->settingFor($organization);
if (! $setting?->isConfigured()) {
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Integrations before collecting online payments.');
}
return $setting;
}
public function organizationMayUseGateway(Organization $organization): bool
{
return $this->plans->canUsePaymentGateway($organization);
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function paystackInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.paystack.co/transaction/initialize', [
'email' => $email !== '' ? $email : 'payer@example.com',
'amount' => $amountMinor,
'currency' => $currency,
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => $metadata,
]);
if (! $response->successful() || ! ($response->json('status') ?? false)) {
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
}
$url = (string) $response->json('data.authorization_url');
if ($url === '') {
throw new RuntimeException('Paystack did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => (string) ($response->json('data.reference') ?: $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? false)
&& strtolower((string) ($data['status'] ?? '')) === 'success';
return [
'paid' => $paid,
'amount_minor' => (int) ($data['amount'] ?? 0),
'reference' => (string) ($data['reference'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function flutterwaveInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.flutterwave.com/v3/payments', [
'tx_ref' => $reference,
'amount' => round($amountMinor / 100, 2),
'currency' => $currency,
'redirect_url' => $callbackUrl,
'customer' => [
'email' => $email !== '' ? $email : 'payer@example.com',
],
'customizations' => [
'title' => (string) ($metadata['title'] ?? 'Payment'),
],
'meta' => $metadata,
]);
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
}
$url = (string) $response->json('data.link');
if ($url === '') {
throw new RuntimeException('Flutterwave did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
'tx_ref' => $reference,
]);
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? '') === 'success'
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
$amountMajor = (float) ($data['amount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => (string) ($data['tx_ref'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function hubtelInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->post('https://payproxyapi.hubtel.com/items/initiate', [
'totalAmount' => round($amountMinor / 100, 2),
'description' => (string) ($metadata['title'] ?? 'Payment'),
'callbackUrl' => $callbackUrl,
'returnUrl' => $callbackUrl,
'merchantAccountNumber' => (string) $setting->public_key,
'cancellationUrl' => $callbackUrl,
'clientReference' => $reference,
]);
if (! $response->successful()) {
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
}
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
if ($url === '') {
throw new RuntimeException('Hubtel did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
{
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
'clientReference' => $reference,
]);
$data = (array) ($response->json('data') ?? $response->json() ?? []);
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
'raw' => $data,
];
}
}