diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index 122b9e9..191d118 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -5,8 +5,8 @@ namespace App\Services\Events; use App\Models\QrCode; use App\Models\QrEventRegistration; use App\Services\Billing\SmsService; -use App\Services\Events\EventEmailService; use App\Services\Meet\EventMeetAccessService; +use App\Services\Pay\PayClient; use App\Services\Payments\MerchantGatewayService; use App\Support\LadillLink; use Illuminate\Support\Str; @@ -15,6 +15,7 @@ use RuntimeException; class EventRegistrationService { public function __construct( + private PayClient $pay, private MerchantGatewayService $gateway, private SubscriptionService $subscriptions, private SmsService $sms, @@ -24,7 +25,7 @@ class EventRegistrationService /** * Register an attendee. Free tiers confirm instantly; paid tiers return a - * merchant-gateway checkout URL and stay pending until payment completes. + * Ladill Pay checkout URL and stay pending until payment completes. * * @param array $data * @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string} @@ -100,7 +101,7 @@ class EventRegistrationService throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.'); } - $meetReturn = trim((string) ($data['meet_return'] ?? '')); + $meetReturn = trim((string) ($data['meet_return'] ?? '')); $metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null; $registration = QrEventRegistration::create([ @@ -125,47 +126,111 @@ class EventRegistrationService return ['registration' => $registration, 'paid' => false, 'checkout_url' => null]; } - $qrCode->loadMissing('user'); $lineName = $mode === 'contributions' ? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName) : sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName); - // Ticket buyers (not till staff): gateways get the attendee email so - // receipts and 3DS challenges belong to the person purchasing. - $buyerEmail = $email; - if ($buyerEmail === '' || ! str_contains($buyerEmail, '@')) { - $buyerEmail = 'buyer+'.($registration->reference).'@checkout.ladill.local'; - } + $feeTier = $mode === 'contributions' ? 'donations' : 'sales'; + $callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback'); - $checkout = $this->gateway->initializeCheckout( - $qrCode->user, - $amountMinor, - (string) ($registration->currency ?? 'GHS'), - $buyerEmail, - LadillLink::path($qrCode->short_code, 'register/callback'), - $registration->reference, - [ - 'title' => $lineName, + $payOrder = $this->pay->createCheckout([ + 'merchant' => $qrCode->user->public_id, + 'fee_tier' => $feeTier, + 'source_service' => 'events', + 'source_ref' => (string) $qrCode->id, + 'callback_url' => $callbackUrl, + 'customer_name' => $name, + 'customer_email' => $email, + 'customer_phone' => $registration->attendee_phone, + 'line_items' => [ + [ + 'name' => $lineName, + 'unit_price_minor' => $amountMinor, + 'quantity' => 1, + ], + ], + 'metadata' => [ 'registration_id' => $registration->id, 'registration_reference' => $registration->reference, 'qr_code_id' => $qrCode->id, 'mode' => $mode, 'attendee_email' => $email, ], - ); + ]); + + $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); + if ($checkoutUrl === '') { + throw new RuntimeException('Could not start checkout. Please try again.'); + } $registration->update([ - 'payment_reference' => $checkout['reference'], + 'pay_order_id' => $payOrder['id'] ?? null, + 'payment_reference' => $payOrder['reference'], + 'metadata' => array_merge((array) $registration->metadata, [ + 'ladill_pay_init' => [ + 'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null, + 'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null, + ], + ]), ]); return [ 'registration' => $registration->fresh(), 'paid' => true, - 'checkout_url' => $checkout['checkout_url'], + 'checkout_url' => $checkoutUrl, ]; } public function complete(string $reference): QrEventRegistration + { + if (str_starts_with($reference, 'LP-')) { + return $this->completeLadillPay($reference); + } + + return $this->completeLegacyGateway($reference); + } + + private function completeLadillPay(string $reference): QrEventRegistration + { + $registration = QrEventRegistration::where('payment_reference', $reference) + ->with('qrCode.user') + ->firstOrFail(); + + if ($registration->status === QrEventRegistration::STATUS_CONFIRMED) { + return $registration; + } + + if ($registration->status !== QrEventRegistration::STATUS_PENDING) { + throw new RuntimeException('This registration can no longer be completed.'); + } + + $payOrder = $this->pay->verify($reference); + + $verifiedMinor = (int) ($payOrder['amount_minor'] ?? 0); + if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) { + $registration->update(['status' => QrEventRegistration::STATUS_FAILED]); + throw new RuntimeException('Payment amount did not match the registration total.'); + } + + $amountMinor = $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor; + + $registration->update([ + 'status' => QrEventRegistration::STATUS_CONFIRMED, + 'paid_at' => now(), + 'amount_minor' => $amountMinor, + 'metadata' => array_merge((array) $registration->metadata, [ + 'ladill_pay' => $payOrder, + 'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2), + ]), + ]); + + $this->notifyConfirmed($registration->fresh('qrCode')); + + return $registration; + } + + /** Legacy merchant-gateway references before Ladill Pay migration. */ + private function completeLegacyGateway(string $reference): QrEventRegistration { $registration = QrEventRegistration::where('payment_reference', $reference) ->where('status', QrEventRegistration::STATUS_PENDING) @@ -179,10 +244,16 @@ class EventRegistrationService throw new RuntimeException('Payment was not successful.'); } + $verifiedMinor = (int) ($result['amount_minor'] ?: 0); + if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) { + $registration->update(['status' => QrEventRegistration::STATUS_FAILED]); + throw new RuntimeException('Payment amount did not match the registration total.'); + } + $registration->update([ 'status' => QrEventRegistration::STATUS_CONFIRMED, 'paid_at' => now(), - 'amount_minor' => (int) ($result['amount_minor'] ?: $registration->amount_minor), + 'amount_minor' => $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor, 'metadata' => array_merge((array) $registration->metadata, [ 'merchant_gateway' => [ 'provider' => $result['provider'], diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index 93ad902..3183cd4 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -18,7 +18,10 @@ $root = config('app.platform_domain', 'ladill.com'); return [ 'apps' => [ + ['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'], ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], + ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], + ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], ['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'], ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], @@ -29,6 +32,7 @@ return [ ['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'], ['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'], ['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'], + ['name' => 'One', 'url' => 'https://one.'.$root.'/sso/connect?redirect='.urlencode('https://one.'.$root.'/dashboard'), 'icon' => 'one.svg'], ['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'], ['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'], ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php index a5d3c03..c4d89a0 100644 --- a/resources/views/qr/account/settings.blade.php +++ b/resources/views/qr/account/settings.blade.php @@ -271,50 +271,10 @@
-

Payment gateway

-

Connect Paystack, Flutterwave, or Hubtel. Ticket and contribution payments go 100% to you — 0% Ladill platform fee. Pro & Business only.

+

Online payments

+

Ticket and contribution payments are processed by Ladill Pay (Paystack). Funds settle to your Ladill wallet — no API keys required.

- @if ($canUsePaymentGateway ?? false) -
- - -
-
-
- - -
-
- - -
-
-
- - -
- - @if ($gateway?->isConfigured()) -

Gateway connected ({{ ucfirst($gateway->provider) }}).

- @else -

Paid checkouts stay disabled until a gateway is connected.

- @endif - @else -
-

Your own payment gateway is a Pro feature

-

Upgrade to Pro or Business to connect Paystack, Flutterwave, or Hubtel for paid tickets and contributions with 0% Ladill fee.

- View plans → -
- @endif +

Ladill Pay is active for paid tickets and contributions on all plans.