Files
ladill-events/app/Services/Events/EventRegistrationService.php
T
isaacclad 69ede57c03
Deploy Ladill Events / deploy (push) Successful in 1m41s
Frame event checkout sheet for ticket buyers.
Use buyer-facing copy, attendee email for gateway receipts, and keep operator wording only for staff wallet top-up.
2026-07-15 13:23:36 +00:00

294 lines
11 KiB
PHP

<?php
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\Payments\MerchantGatewayService;
use App\Support\LadillLink;
use Illuminate\Support\Str;
use RuntimeException;
class EventRegistrationService
{
public function __construct(
private MerchantGatewayService $gateway,
private SubscriptionService $subscriptions,
private SmsService $sms,
private EventEmailService $email,
private EventMeetAccessService $meetAccess,
) {}
/**
* Register an attendee. Free tiers confirm instantly; paid tiers return a
* merchant-gateway checkout URL and stay pending until payment completes.
*
* @param array<string, mixed> $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);
$qrCode->loadMissing('user');
if ($qrCode->user && ! $this->subscriptions->canAcceptTicket($qrCode->user)) {
throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.');
}
$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');
$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';
}
$checkout = $this->gateway->initializeCheckout(
$qrCode->user,
$amountMinor,
(string) ($registration->currency ?? 'GHS'),
$buyerEmail,
LadillLink::path($qrCode->short_code, 'register/callback'),
$registration->reference,
[
'title' => $lineName,
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
'qr_code_id' => $qrCode->id,
'mode' => $mode,
'attendee_email' => $email,
],
);
$registration->update([
'payment_reference' => $checkout['reference'],
]);
return [
'registration' => $registration->fresh(),
'paid' => true,
'checkout_url' => $checkout['checkout_url'],
];
}
public function complete(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->with('qrCode.user')
->firstOrFail();
$owner = $registration->qrCode?->user ?? $registration->organizer;
$result = $this->gateway->verify($owner, $reference);
if (! $result['paid']) {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'amount_minor' => (int) ($result['amount_minor'] ?: $registration->amount_minor),
'metadata' => array_merge((array) $registration->metadata, [
'merchant_gateway' => [
'provider' => $result['provider'],
'reference' => $result['reference'],
'raw' => $result['raw'],
],
'platform_fee_ghs' => 0,
]),
]);
$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<string, mixed> $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;
}
}