$data * @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string} */ public function register(QrCode $qrCode, array $data): array { $content = $qrCode->content(); $mode = ($content['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing'; if (empty($content['registration_open'])) { throw new RuntimeException($mode === 'contributions' ? 'Contributions for this event are closed.' : 'Registration for this event is closed.'); } $tierName = trim((string) ($data['tier'] ?? '')); $tier = null; if ($mode === 'contributions') { if (! in_array($tierName, $content['contribution_categories'] ?? [], true)) { throw new RuntimeException('Select a valid contribution category.'); } } else { $tier = collect($content['tiers'] ?? [])->firstWhere('name', $tierName); if (! $tier) { throw new RuntimeException('Select a valid ticket type.'); } } $name = trim((string) ($data['attendee_name'] ?? '')); if ($name === '') { throw new RuntimeException('Enter your full name.'); } $email = trim((string) ($data['attendee_email'] ?? '')); if ($email === '' || ! str_contains($email, '@')) { throw new RuntimeException('Enter a valid email address.'); } if ($mode === 'ticketing') { $capacity = (int) ($tier['capacity'] ?? 0); if ($capacity > 0) { $taken = QrEventRegistration::query() ->where('qr_code_id', $qrCode->id) ->where('tier_name', $tierName) ->where('status', QrEventRegistration::STATUS_CONFIRMED) ->count(); if ($taken >= $capacity) { throw new RuntimeException('Sorry, "'.$tierName.'" is sold out.'); } } } $badgeFields = []; foreach ($content['badge_fields'] ?? [] as $label) { $value = trim((string) (($data['badge_fields'] ?? [])[$label] ?? '')); if ($value !== '') { $badgeFields[$label] = mb_substr($value, 0, 120); } } if ($mode === 'contributions') { $priceGhs = round((float) ($data['amount'] ?? 0), 2); if ($priceGhs <= 0) { throw new RuntimeException('Enter a contribution amount.'); } } else { $priceGhs = round((float) ($tier['price'] ?? 0), 2); } $amountMinor = (int) round($priceGhs * 100); $meetReturn = trim((string) ($data['meet_return'] ?? '')); $metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null; $registration = QrEventRegistration::create([ 'qr_code_id' => $qrCode->id, 'user_id' => $qrCode->user_id, 'reference' => 'QRE-'.strtoupper(Str::random(16)), 'badge_code' => $this->uniqueBadgeCode(), 'tier_name' => $tierName, 'amount_minor' => $amountMinor, 'currency' => $content['currency'] ?? 'GHS', 'attendee_name' => mb_substr($name, 0, 120), 'attendee_email' => $email, 'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null, 'badge_fields' => $badgeFields ?: null, 'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED, 'metadata' => $metadata, ]); if ($amountMinor === 0) { $this->notifyConfirmed($registration->fresh('qrCode')); return ['registration' => $registration, 'paid' => false, 'checkout_url' => null]; } $qrCode->loadMissing('user'); $feeTier = $mode === 'contributions' ? 'donations' : 'sales'; $lineName = $mode === 'contributions' ? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName) : sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName); $payOrder = $this->pay->createCheckout([ 'merchant' => $qrCode->user->public_id, 'fee_tier' => $feeTier, 'source_service' => 'events', 'source_ref' => (string) $qrCode->id, 'callback_url' => LadillLink::path($qrCode->short_code, 'register/callback'), 'customer_name' => $registration->attendee_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, ], ]); $registration->update([ 'pay_order_id' => $payOrder['id'] ?? null, 'payment_reference' => $payOrder['reference'], ]); $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); if ($checkoutUrl === '') { throw new RuntimeException('Could not start checkout. Please try again.'); } return [ 'registration' => $registration->fresh(), 'paid' => true, 'checkout_url' => $checkoutUrl, ]; } public function complete(string $reference): QrEventRegistration { if (str_starts_with($reference, 'LP-')) { return $this->completeLadillPay($reference); } return $this->completeLegacy($reference); } private function completeLadillPay(string $reference): QrEventRegistration { $registration = QrEventRegistration::where('payment_reference', $reference) ->where('status', QrEventRegistration::STATUS_PENDING) ->firstOrFail(); $payOrder = $this->pay->verify($reference); $platformFeeGhs = ((int) ($payOrder['platform_fee_minor'] ?? 0)) / 100; $registration->update([ 'status' => QrEventRegistration::STATUS_CONFIRMED, 'paid_at' => now(), 'pay_order_id' => $payOrder['id'] ?? $registration->pay_order_id, 'metadata' => array_merge((array) $registration->metadata, [ 'ladill_pay' => $payOrder, 'platform_fee_ghs' => $platformFeeGhs, ]), ]); $this->notifyConfirmed($registration->fresh('qrCode')); return $registration; } /** Legacy QREP-* references before Ladill Pay migration. */ private function completeLegacy(string $reference): QrEventRegistration { $registration = QrEventRegistration::where('payment_reference', $reference) ->where('status', QrEventRegistration::STATUS_PENDING) ->firstOrFail(); $data = $this->paystack->verifyTransaction($reference); if (($data['status'] ?? '') !== 'success') { $registration->update(['status' => QrEventRegistration::STATUS_FAILED]); throw new RuntimeException('Payment was not successful.'); } $feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.035 : 0.055; $paidAmount = round(($data['amount'] ?? 0) / 100, 2); $platformFee = round($paidAmount * $feeRate, 2); $organizerAmountMinor = (int) round(($paidAmount - $platformFee) * 100); $registration->update([ 'status' => QrEventRegistration::STATUS_CONFIRMED, 'paid_at' => now(), 'metadata' => array_merge((array) $registration->metadata, [ 'paystack' => $data, 'platform_fee_ghs' => $platformFee, 'legacy' => true, ]), ]); $mode = ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing'; $this->billing->credit( $registration->organizer->public_id, $organizerAmountMinor, 'events', 'pay', $reference, $registration->id, sprintf( '%s — %s (%s)', $mode === 'contributions' ? 'Event contribution' : 'Event ticket', $registration->qrCode->label, $registration->tier_name ), ); $this->notifyConfirmed($registration->fresh('qrCode')); return $registration; } private function notifyConfirmed(QrEventRegistration $registration): void { $registration->loadMissing('qrCode.user'); $ownerPublicId = (string) $registration->qrCode?->user?->public_id; if ($ownerPublicId === '') { return; } $content = $registration->qrCode?->content() ?? []; $eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name'); $firstName = explode(' ', $registration->attendee_name)[0]; $joinUrl = $this->nextVirtualJoinUrl($content); if ($registration->attendee_email) { $this->email->sendRegistrationConfirmation( $ownerPublicId, $registration->attendee_email, $eventName, $registration->badge_code, $joinUrl !== '' ? $joinUrl : null, $registration->attendee_name, $registration->qrCode?->user?->email, $registration->qrCode?->user?->name, ); } if ($registration->attendee_phone) { if (($content['mode'] ?? 'ticketing') === 'contributions') { $message = sprintf( 'Hi %s, thank you for your %s of %s %s to %s. Ref: %s', $firstName, $registration->tier_name, $registration->currency, number_format($registration->amountCedis(), 2), $eventName, $registration->badge_code ); } else { $message = sprintf( 'Hi %s, you are registered for %s (%s). Badge code: %s', $firstName, $eventName, $registration->tier_name, $registration->badge_code ); } if ($joinUrl !== '') { $message .= ' Join: '.$joinUrl; } $this->sms->send( $ownerPublicId, $registration->attendee_phone, $message, ); } $this->meetAccess->syncRegistration($registration->fresh()); } /** @param array $content */ private function nextVirtualJoinUrl(array $content): string { $format = (string) ($content['format'] ?? 'in_person'); if (! in_array($format, ['virtual', 'hybrid'], true)) { return ''; } foreach ((array) ($content['virtual_sessions'] ?? []) as $session) { if (! is_array($session)) { continue; } $joinUrl = trim((string) ($session['join_url'] ?? '')); if ($joinUrl !== '') { return $joinUrl; } } return ''; } private function uniqueBadgeCode(): string { do { $code = strtoupper(Str::random(8)); } while (QrEventRegistration::where('badge_code', $code)->exists()); return $code; } }