Deploy Ladill Events / deploy (push) Successful in 55s
Make Ladill Events free for all accounts, remove plan upgrade UI, force Ladill Pay only for tickets and contributions, and reinstate standard platform fees.
311 lines
12 KiB
PHP
311 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payments;
|
|
|
|
use App\Models\PaymentGatewaySetting;
|
|
use App\Models\User;
|
|
use App\Services\Events\SubscriptionService;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
class MerchantGatewayService
|
|
{
|
|
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
|
|
{
|
|
// Custom / owner payment gateways are retired. All checkouts use Ladill Pay
|
|
// with platform fees.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @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 {
|
|
$setting = $this->requireConfigured($owner);
|
|
$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(User|string $owner, string $reference): array
|
|
{
|
|
// Do not re-check the current plan here: an owner-gateway payment
|
|
// initiated before a downgrade must remain verifiable.
|
|
$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;
|
|
}
|
|
|
|
protected function ownerMayUseGateway(User|string $owner): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
];
|
|
}
|
|
}
|