Pass access_code into the payment sheet, launch Paystack Inline only, and hide Ladill chrome after Inline loads so register Card/MoMo shows a single payment interface.
371 lines
14 KiB
PHP
371 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payments;
|
|
|
|
use App\Models\PaymentGatewaySetting;
|
|
use App\Models\User;
|
|
use App\Services\Pay\PayClient;
|
|
use App\Services\Pos\SubscriptionService;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
class MerchantGatewayService
|
|
{
|
|
public function __construct(private readonly PayClient $pay) {}
|
|
|
|
public function settingFor(User|string $owner): ?PaymentGatewaySetting
|
|
{
|
|
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
|
|
|
|
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
|
|
}
|
|
|
|
public function isConfigured(User|string $owner): bool
|
|
{
|
|
return $this->shouldUseOwnerGateway($owner);
|
|
}
|
|
|
|
public function shouldUseOwnerGateway(User|string $owner): bool
|
|
{
|
|
return $this->ownerMayUseGateway($owner)
|
|
&& (bool) $this->settingFor($owner)?->isConfigured();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
* @return array{checkout_url: string, reference: string, provider: string}
|
|
*/
|
|
public function initializeCheckout(
|
|
User|string $owner,
|
|
int $amountMinor,
|
|
string $currency,
|
|
string $email,
|
|
string $callbackUrl,
|
|
string $reference,
|
|
array $metadata = [],
|
|
): array {
|
|
$currency = strtoupper($currency ?: 'GHS');
|
|
$email = (string) config('pay.mini_checkout_email', 'pay@ladill.com');
|
|
|
|
if (! $this->shouldUseOwnerGateway($owner)) {
|
|
return $this->ladillPayInitialize($owner, $amountMinor, $email, $callbackUrl, $reference, $metadata);
|
|
}
|
|
|
|
$setting = $this->requireConfigured($owner);
|
|
|
|
try {
|
|
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.'),
|
|
};
|
|
} catch (\Throwable) {
|
|
return $this->ladillPayInitialize($owner, $amountMinor, $email, $callbackUrl, $reference, $metadata);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
|
*/
|
|
public function verify(User|string $owner, string $reference): array
|
|
{
|
|
if (str_starts_with($reference, 'LP-')) {
|
|
$result = $this->pay->verify($reference);
|
|
|
|
return [
|
|
'paid' => in_array(strtolower((string) ($result['status'] ?? '')), ['paid', 'success'], true),
|
|
'amount_minor' => (int) ($result['amount_minor'] ?? 0),
|
|
'reference' => (string) ($result['reference'] ?? $reference),
|
|
'provider' => 'ladill_pay',
|
|
'raw' => $result,
|
|
];
|
|
}
|
|
|
|
// Verification intentionally ignores current plan state so payments
|
|
// initiated before a downgrade can still complete safely.
|
|
$setting = $this->settingFor($owner);
|
|
if (! $setting?->isConfigured()) {
|
|
throw new RuntimeException('The payment gateway used for this transaction is no longer configured.');
|
|
}
|
|
|
|
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(User|string $owner): PaymentGatewaySetting
|
|
{
|
|
if (! $this->ownerMayUseGateway($owner)) {
|
|
throw new RuntimeException('Connecting your own payment gateway requires a paid plan. Upgrade to Pro or Business.');
|
|
}
|
|
|
|
$setting = $this->settingFor($owner);
|
|
if (! $setting?->isConfigured()) {
|
|
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
|
|
}
|
|
|
|
return $setting;
|
|
}
|
|
|
|
/** @return array{checkout_url:string,reference:string,provider:string} */
|
|
private function ladillPayInitialize(User|string $owner, int $amountMinor, string $email, string $callbackUrl, string $reference, array $metadata): array
|
|
{
|
|
$ownerRef = $owner instanceof User ? (string) $owner->public_id : (string) $owner;
|
|
$checkout = $this->pay->createCheckout([
|
|
'merchant' => $ownerRef,
|
|
'fee_tier' => 'sales',
|
|
'source_service' => 'pos',
|
|
'source_ref' => (string) ($metadata['pos_sale_id'] ?? $metadata['pos_payment_id'] ?? $reference),
|
|
'callback_url' => $callbackUrl,
|
|
'customer_email' => $email,
|
|
'line_items' => [[
|
|
'name' => (string) ($metadata['title'] ?? 'POS payment'),
|
|
'unit_price_minor' => $amountMinor,
|
|
'quantity' => 1,
|
|
]],
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
return [
|
|
'checkout_url' => (string) $checkout['checkout_url'],
|
|
'access_code' => (string) ($checkout['access_code'] ?? ''),
|
|
'reference' => (string) $checkout['reference'],
|
|
'provider' => 'ladill_pay',
|
|
];
|
|
}
|
|
|
|
protected function ownerMayUseGateway(User|string $owner): bool
|
|
{
|
|
$user = $owner instanceof User
|
|
? $owner
|
|
: User::query()->where('public_id', (string) $owner)->first();
|
|
|
|
if (! $user) {
|
|
return false;
|
|
}
|
|
|
|
return app(SubscriptionService::class)->canUsePaymentGateway($user);
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
'access_code' => (string) ($response->json('data.access_code') ?? ''),
|
|
'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,
|
|
];
|
|
}
|
|
}
|