Add optional owner gateway routing to Give.
Deploy Ladill Give / deploy (push) Successful in 1m0s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-21 19:47:13 +00:00
co-authored by Cursor
parent 4ab2fcb3ad
commit e5d95167dd
9 changed files with 330 additions and 4 deletions
+34 -1
View File
@@ -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.');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentGatewaySetting extends Model
{
public const PROVIDER_PAYSTACK = 'paystack';
protected $fillable = ['owner_ref', 'provider', 'public_key', 'secret_key', 'is_active', 'metadata'];
protected function casts(): array
{
return [
'public_key' => '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_');
}
}
+9
View File
@@ -45,6 +45,15 @@ class BillingClient
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
}
/** @return array<string,mixed> */
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.
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Billing;
use App\Models\User;
use Throwable;
class PlanEntitlementService
{
public const CUSTOM_PAYMENT_GATEWAY = 'custom_payment_gateway';
public function __construct(private readonly BillingClient $billing) {}
public function has(User $owner, string $feature): bool
{
try {
return in_array($feature, (array) ($this->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);
}
}
+49 -3
View File
@@ -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) {
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use App\Services\Billing\PlanEntitlementService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function __construct(private readonly PlanEntitlementService $entitlements) {}
public function settingFor(User|string $owner): ?PaymentGatewaySetting
{
$ownerRef = $owner instanceof User ? (string) $owner->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<string,mixed> */
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,
];
}
}