diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index de7c9a9..b90a146 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -3,15 +3,23 @@ namespace App\Http\Controllers\Qr; use App\Http\Controllers\Controller; +use App\Models\PaymentGatewaySetting; use App\Models\QrSetting; use App\Services\Billing\BillingClient; +use App\Services\Billing\PlanEntitlementService; +use App\Services\Payments\MerchantGatewayService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\View\View; class AccountController extends Controller { - public function __construct(private BillingClient $billing) {} + public function __construct( + private BillingClient $billing, + private PlanEntitlementService $entitlements, + private MerchantGatewayService $gateway, + ) {} private function topupUrl(): string { @@ -65,6 +73,8 @@ class AccountController extends Controller return view('give.settings', [ 'account' => $account, 'settings' => $settings, + 'gateway' => $this->gateway->settingFor($account), + 'canUsePaymentGateway' => $this->entitlements->canUseCustomPaymentGateway($account), ]); } @@ -77,6 +87,10 @@ class AccountController extends Controller 'product_updates' => ['nullable', 'boolean'], 'notify_registrations' => ['nullable', 'boolean'], 'notify_payouts' => ['nullable', 'boolean'], + 'gateway_provider' => ['nullable', Rule::in(['', PaymentGatewaySetting::PROVIDER_PAYSTACK])], + 'gateway_public_key' => ['nullable', 'string', 'max:2000'], + 'gateway_secret_key' => ['nullable', 'string', 'max:2000'], + 'gateway_is_active' => ['nullable', 'boolean'], ]); QrSetting::updateOrCreate( @@ -89,6 +103,25 @@ class AccountController extends Controller ], ); + if ($this->entitlements->canUseCustomPaymentGateway($account)) { + $provider = trim((string) ($data['gateway_provider'] ?? '')); + $setting = PaymentGatewaySetting::query()->where('owner_ref', $account->public_id)->first(); + if ($provider !== '' || $setting) { + $setting ??= new PaymentGatewaySetting(['owner_ref' => $account->public_id]); + if ($provider !== '') { + $setting->provider = $provider; + } + if (filled($data['gateway_public_key'] ?? null)) { + $setting->public_key = $data['gateway_public_key']; + } + if (filled($data['gateway_secret_key'] ?? null)) { + $setting->secret_key = $data['gateway_secret_key']; + } + $setting->is_active = $provider !== '' && $request->boolean('gateway_is_active'); + $setting->save(); + } + } + return redirect()->route('account.settings')->with('success', 'Settings saved.'); } } diff --git a/app/Models/PaymentGatewaySetting.php b/app/Models/PaymentGatewaySetting.php new file mode 100644 index 0000000..75f8a14 --- /dev/null +++ b/app/Models/PaymentGatewaySetting.php @@ -0,0 +1,30 @@ + 'encrypted', + 'secret_key' => 'encrypted', + 'is_active' => 'boolean', + 'metadata' => 'array', + ]; + } + + public function isConfigured(): bool + { + return $this->is_active + && $this->provider === self::PROVIDER_PAYSTACK + && str_starts_with(trim((string) $this->public_key), 'pk_') + && str_starts_with(trim((string) $this->secret_key), 'sk_'); + } +} diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4c7caef..6e51dcb 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -45,6 +45,15 @@ class BillingClient return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]); } + /** @return array */ + public function suiteEntitlement(string $publicId): array + { + return (array) ($this->get('/suite-entitlements', [ + 'user' => $publicId, + 'app' => (string) config('billing.service', 'give'), + ])['data'] ?? []); + } + /** * Debit the wallet. Returns true on success, false on insufficient balance * (HTTP 402). Idempotent by $reference. diff --git a/app/Services/Billing/PlanEntitlementService.php b/app/Services/Billing/PlanEntitlementService.php new file mode 100644 index 0000000..d318d40 --- /dev/null +++ b/app/Services/Billing/PlanEntitlementService.php @@ -0,0 +1,27 @@ +billing->suiteEntitlement((string) $owner->public_id)['features'] ?? []), true); + } catch (Throwable) { + return false; + } + } + + public function canUseCustomPaymentGateway(User $owner): bool + { + return $this->has($owner, self::CUSTOM_PAYMENT_GATEWAY); + } +} diff --git a/app/Services/Give/GiveDonationService.php b/app/Services/Give/GiveDonationService.php index 7745f86..b503de3 100644 --- a/app/Services/Give/GiveDonationService.php +++ b/app/Services/Give/GiveDonationService.php @@ -8,6 +8,7 @@ use App\Services\Billing\BillingClient; use App\Services\Billing\PaystackService; use App\Services\Billing\SmsService; use App\Services\Pay\PayClient; +use App\Services\Payments\MerchantGatewayService; use App\Support\LadillLink; use Illuminate\Support\Str; use RuntimeException; @@ -19,6 +20,7 @@ class GiveDonationService private PaystackService $paystack, private BillingClient $billing, private SmsService $sms, + private MerchantGatewayService $gateway, ) {} /** @@ -63,7 +65,22 @@ class GiveDonationService 'payment_reference' => null, ]); - $payOrder = $this->pay->createCheckout([ + $usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user); + $payOrder = null; + if ($usesOwnerGateway) { + try { + $payOrder = $this->gateway->initialize( + $qrCode->user, $amountMinor, (string) ($qrCode->content()['currency'] ?? 'GHS'), + (string) config('pay.mini_checkout_email', 'pay@ladill.com'), + $callbackUrl, 'GGP-'.strtoupper(Str::random(16)), + ['give_donation_id' => $donation->id, 'give_reference' => $reference, 'qr_code_id' => $qrCode->id], + ); + } catch (\Throwable) { + $usesOwnerGateway = false; + } + } + if (! $usesOwnerGateway) { + $payOrder = $this->pay->createCheckout([ 'merchant' => $qrCode->user->public_id, 'fee_tier' => 'donations', 'source_service' => 'give', @@ -85,10 +102,11 @@ class GiveDonationService 'qr_code_id' => $qrCode->id, 'collection_type' => $collectionType, ], - ]); + ]); + } $donation->update([ - 'pay_order_id' => $payOrder['id'] ?? null, + 'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null), 'payment_reference' => $payOrder['reference'], 'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null, 'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null, @@ -113,6 +131,9 @@ class GiveDonationService if (str_starts_with($paymentReference, 'LP-')) { return $this->completeLadillPay($paymentReference); } + if (str_starts_with($paymentReference, 'GGP-')) { + return $this->completeOwnerGateway($paymentReference); + } return $this->completeLegacy($paymentReference); } @@ -182,6 +203,31 @@ class GiveDonationService return $donation; } + private function completeOwnerGateway(string $reference): GiveDonation + { + $donation = GiveDonation::where('payment_reference', $reference) + ->where('status', GiveDonation::STATUS_PENDING) + ->with('merchant') + ->firstOrFail(); + $result = $this->gateway->verify($donation->merchant, $reference); + if (! $result['paid']) { + $donation->update(['status' => GiveDonation::STATUS_FAILED]); + throw new RuntimeException('Payment was not successful.'); + } + $paidMinor = (int) ($result['amount_minor'] ?: $donation->amount_minor); + $donation->update([ + 'status' => GiveDonation::STATUS_PAID, + 'amount_minor' => $paidMinor, + 'platform_fee_minor' => 0, + 'merchant_amount_minor' => $paidMinor, + 'paid_at' => now(), + 'metadata' => array_merge((array) $donation->metadata, ['merchant_gateway' => $result]), + ]); + $this->notifyDonor($donation->fresh(['qrCode', 'merchant'])); + + return $donation; + } + private function notifyDonor(GiveDonation $donation): void { if (! $donation->payer_phone) { diff --git a/app/Services/Payments/MerchantGatewayService.php b/app/Services/Payments/MerchantGatewayService.php new file mode 100644 index 0000000..e795ec8 --- /dev/null +++ b/app/Services/Payments/MerchantGatewayService.php @@ -0,0 +1,77 @@ +public_id : (string) $owner; + + return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first(); + } + + public function shouldUseOwnerGateway(User $owner): bool + { + return $this->entitlements->canUseCustomPaymentGateway($owner) + && (bool) $this->settingFor($owner)?->isConfigured(); + } + + /** @return array{checkout_url:string,reference:string,provider:string} */ + public function initialize(User $owner, int $amountMinor, string $currency, string $email, string $callbackUrl, string $reference, array $metadata = []): array + { + if (! $this->shouldUseOwnerGateway($owner)) { + throw new RuntimeException('Owner gateway is not available.'); + } + + $setting = $this->settingFor($owner); + $response = Http::withToken((string) $setting->secret_key)->acceptJson()->timeout(20) + ->post('https://api.paystack.co/transaction/initialize', [ + 'email' => $email !== '' ? $email : 'pay@ladill.com', + 'amount' => $amountMinor, + 'currency' => strtoupper($currency ?: 'GHS'), + 'reference' => $reference, + 'callback_url' => $callbackUrl, + 'metadata' => $metadata, + ]); + + if (! $response->successful() || ! ($response->json('status') ?? false) || ! $response->json('data.authorization_url')) { + throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.'); + } + + return [ + 'checkout_url' => (string) $response->json('data.authorization_url'), + 'reference' => (string) ($response->json('data.reference') ?: $reference), + 'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK, + ]; + } + + /** @return array */ + public function verify(User|string $owner, string $reference): array + { + $setting = $this->settingFor($owner); + if (! $setting?->isConfigured()) { + throw new RuntimeException('The owner gateway used for this payment is no longer configured.'); + } + + $response = Http::withToken((string) $setting->secret_key)->acceptJson()->timeout(20) + ->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference)); + $data = (array) ($response->json('data') ?? []); + + return [ + 'paid' => ($response->json('status') ?? false) && strtolower((string) ($data['status'] ?? '')) === 'success', + 'amount_minor' => (int) ($data['amount'] ?? 0), + 'reference' => (string) ($data['reference'] ?? $reference), + 'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK, + 'raw' => $data, + ]; + } +} diff --git a/database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php b/database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php new file mode 100644 index 0000000..c9cb7c2 --- /dev/null +++ b/database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('owner_ref', 64)->unique(); + $table->string('provider', 32); + $table->text('public_key')->nullable(); + $table->text('secret_key')->nullable(); + $table->boolean('is_active')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_gateway_settings'); + } +}; diff --git a/resources/views/give/settings.blade.php b/resources/views/give/settings.blade.php index 4242ca2..ff6912a 100644 --- a/resources/views/give/settings.blade.php +++ b/resources/views/give/settings.blade.php @@ -56,6 +56,32 @@ +
+
+

Donor payment engine

+

Ladill Pay is recommended, zero-config, and selected by default.

+
+

Ladill Pay remains the safe fallback and requires no owner keys.

+ @if ($canUsePaymentGateway ?? false) + +
+ + +
+ +

Optional. Invalid or incomplete keys automatically fall back to Ladill Pay.

+ @else +

Free uses Ladill Pay only. Pro and higher plans may optionally connect owner Paystack.

+ @endif +
+ diff --git a/tests/Feature/PaymentGatewayPolicyTest.php b/tests/Feature/PaymentGatewayPolicyTest.php new file mode 100644 index 0000000..c58a69e --- /dev/null +++ b/tests/Feature/PaymentGatewayPolicyTest.php @@ -0,0 +1,51 @@ + 'https://ladill.com/api/billing', 'billing.api_key' => 'give-test']); + $owner = User::factory()->create(['public_id' => 'usr_give_policy']); + $setting = PaymentGatewaySetting::create([ + 'owner_ref' => $owner->public_id, 'provider' => 'paystack', + 'public_key' => 'pk_test_saved', 'secret_key' => 'sk_test_saved', 'is_active' => true, + ]); + + Http::fakeSequence() + ->push(['data' => ['effective_plan' => 'free', 'features' => []]]) + ->push(['data' => ['effective_plan' => 'enterprise', 'features' => ['custom_payment_gateway']]]) + ->push(['data' => ['effective_plan' => 'enterprise', 'features' => ['custom_payment_gateway']]]); + $this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + + $setting->update(['is_active' => false]); + $this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + $setting->update(['is_active' => true]); + $this->assertTrue(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + } + + public function test_invalid_pro_credentials_fall_back_to_ladill_pay(): void + { + config(['billing.api_url' => 'https://ladill.com/api/billing', 'billing.api_key' => 'give-test']); + Http::fake(['*/suite-entitlements*' => Http::response(['data' => [ + 'effective_plan' => 'pro', 'features' => ['custom_payment_gateway'], + ]])]); + $owner = User::factory()->create(['public_id' => 'usr_give_invalid']); + PaymentGatewaySetting::create([ + 'owner_ref' => $owner->public_id, 'provider' => 'paystack', + 'public_key' => 'bad', 'secret_key' => 'bad', 'is_active' => true, + ]); + + $this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + } +}