Add optional owner gateway routing to Merchant.
Deploy Ladill Merchant / deploy (push) Successful in 44s
Deploy Ladill Merchant / deploy (push) Successful in 44s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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('merchant.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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_');
|
||||
}
|
||||
}
|
||||
@@ -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', 'merchant'),
|
||||
])['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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,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 App\Support\Qr\QrTypeCatalog;
|
||||
use RuntimeException;
|
||||
@@ -22,6 +23,7 @@ class MerchantSaleService
|
||||
private BillingClient $billing,
|
||||
private SmsService $sms,
|
||||
private KitchenPusher $kitchen,
|
||||
private MerchantGatewayService $gateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -53,7 +55,23 @@ class MerchantSaleService
|
||||
$customerEmail = trim((string) ($data['customer_email'] ?? ''));
|
||||
$callbackUrl = LadillLink::path($qrCode->short_code, 'order/callback');
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
|
||||
$payOrder = null;
|
||||
if ($usesOwnerGateway) {
|
||||
try {
|
||||
$payOrder = $this->gateway->initialize(
|
||||
$qrCode->user, (int) round($totalGhs * 100),
|
||||
(string) ($qrCode->content()['currency'] ?? 'GHS'),
|
||||
(string) config('pay.mini_checkout_email', 'pay@ladill.com'), $callbackUrl,
|
||||
'MGP-'.strtoupper(\Illuminate\Support\Str::random(16)),
|
||||
['qr_code_id' => $qrCode->id, 'short_code' => $qrCode->short_code, 'type' => 'order'],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$usesOwnerGateway = false;
|
||||
}
|
||||
}
|
||||
if (! $usesOwnerGateway) {
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => 'sales',
|
||||
'source_service' => 'merchant',
|
||||
@@ -71,17 +89,18 @@ class MerchantSaleService
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'short_code' => $qrCode->short_code,
|
||||
],
|
||||
]);
|
||||
]);
|
||||
}
|
||||
|
||||
$order = QrSaleOrder::create([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'user_id' => $qrCode->user_id,
|
||||
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
|
||||
'customer_email' => $customerEmail ?: null,
|
||||
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
|
||||
'items' => $items,
|
||||
'amount_ghs' => round(($payOrder['amount_minor'] ?? 0) / 100, 2),
|
||||
'amount_ghs' => $usesOwnerGateway ? $totalGhs : round(($payOrder['amount_minor'] ?? 0) / 100, 2),
|
||||
'status' => QrSaleOrder::STATUS_PENDING,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
]);
|
||||
@@ -154,7 +173,23 @@ class MerchantSaleService
|
||||
}
|
||||
|
||||
$lineLabel = sprintf('%s — %s', $serviceName, $startsAt->format('D j M, g:i A'));
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
|
||||
$payOrder = null;
|
||||
if ($usesOwnerGateway) {
|
||||
try {
|
||||
$payOrder = $this->gateway->initialize(
|
||||
$qrCode->user, (int) round($priceGhs * 100),
|
||||
(string) ($content['currency'] ?? 'GHS'),
|
||||
(string) config('pay.mini_checkout_email', 'pay@ladill.com'), $callbackUrl,
|
||||
'MGP-'.strtoupper(\Illuminate\Support\Str::random(16)),
|
||||
['qr_code_id' => $qrCode->id, 'qr_booking_id' => $booking->id, 'type' => 'booking'],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$usesOwnerGateway = false;
|
||||
}
|
||||
}
|
||||
if (! $usesOwnerGateway) {
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => 'sales',
|
||||
'source_service' => 'merchant',
|
||||
@@ -171,10 +206,11 @@ class MerchantSaleService
|
||||
'qr_booking_id' => $booking->id,
|
||||
'type' => 'booking',
|
||||
],
|
||||
]);
|
||||
]);
|
||||
}
|
||||
|
||||
$booking->update([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
]);
|
||||
|
||||
@@ -192,6 +228,9 @@ class MerchantSaleService
|
||||
if (str_starts_with($reference, 'LP-')) {
|
||||
return $this->completeLadillPayBooking($reference);
|
||||
}
|
||||
if (str_starts_with($reference, 'MGP-')) {
|
||||
return $this->completeOwnerGatewayBooking($reference);
|
||||
}
|
||||
|
||||
return $this->completeLegacyBooking($reference);
|
||||
}
|
||||
@@ -201,6 +240,9 @@ class MerchantSaleService
|
||||
if (str_starts_with($reference, 'LP-')) {
|
||||
return $this->completeLadillPay($reference);
|
||||
}
|
||||
if (str_starts_with($reference, 'MGP-')) {
|
||||
return $this->completeOwnerGateway($reference);
|
||||
}
|
||||
|
||||
return $this->completeLegacy($reference);
|
||||
}
|
||||
@@ -269,6 +311,32 @@ class MerchantSaleService
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function completeOwnerGateway(string $reference): QrSaleOrder
|
||||
{
|
||||
$order = QrSaleOrder::where('payment_reference', $reference)
|
||||
->where('status', QrSaleOrder::STATUS_PENDING)
|
||||
->with('merchant')
|
||||
->firstOrFail();
|
||||
$result = $this->gateway->verify($order->merchant, $reference);
|
||||
if (! $result['paid']) {
|
||||
$order->update(['status' => QrSaleOrder::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
$paidMinor = (int) ($result['amount_minor'] ?: round($order->amount_ghs * 100));
|
||||
$order->update([
|
||||
'status' => QrSaleOrder::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'amount_ghs' => round($paidMinor / 100, 2),
|
||||
'platform_fee_ghs' => 0,
|
||||
'merchant_amount_ghs' => round($paidMinor / 100, 2),
|
||||
'metadata' => array_merge((array) $order->metadata, ['merchant_gateway' => $result]),
|
||||
]);
|
||||
$this->notifyOrderPlaced($order->fresh(['qrCode', 'merchant']));
|
||||
$this->kitchen->push($order->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function completeLadillPayBooking(string $reference): QrBooking
|
||||
{
|
||||
$booking = QrBooking::where('payment_reference', $reference)
|
||||
@@ -332,6 +400,32 @@ class MerchantSaleService
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function completeOwnerGatewayBooking(string $reference): QrBooking
|
||||
{
|
||||
$booking = QrBooking::where('payment_reference', $reference)
|
||||
->where('status', QrBooking::STATUS_PENDING)
|
||||
->with('merchant')
|
||||
->firstOrFail();
|
||||
$result = $this->gateway->verify($booking->merchant, $reference);
|
||||
if (! $result['paid']) {
|
||||
$booking->update(['status' => QrBooking::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
$paidMinor = (int) ($result['amount_minor'] ?: round($booking->amount_ghs * 100));
|
||||
$booking->update([
|
||||
'status' => QrBooking::STATUS_CONFIRMED,
|
||||
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'amount_ghs' => round($paidMinor / 100, 2),
|
||||
'platform_fee_ghs' => 0,
|
||||
'merchant_amount_ghs' => round($paidMinor / 100, 2),
|
||||
'metadata' => array_merge((array) $booking->metadata, ['merchant_gateway' => $result]),
|
||||
]);
|
||||
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function notifyBookingConfirmed(QrBooking $booking): void
|
||||
{
|
||||
if (! $booking->customer_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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user