Files
ladill-care/app/Services/Payments/MerchantGatewayService.php
isaacclad 7a0a7fcb88
Deploy Ladill Care / deploy (push) Successful in 1m0s
Add Paystack, Flutterwave, and Hubtel for encounter billing.
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.
2026-07-16 10:24:28 +00:00

307 lines
12 KiB
PHP

<?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,
];
}
}