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 $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} */ 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 $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} */ 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 $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} */ 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 $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} */ 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, ]; } }