1 Commits
Author SHA1 Message Date
isaaccladandClaude Opus 4.8 8c4aecfaee Add public product landing page at /
Serve a marketing landing page at the site root via ProductLandingController
instead of redirecting straight to login, with config/product_landing.php and
product views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:05:58 +00:00
205 changed files with 1451 additions and 13872 deletions
+2 -48
View File
@@ -17,14 +17,8 @@ DB_DATABASE=ladill_events
DB_USERNAME=ladill_events
DB_PASSWORD=
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=redis
# Idle web session length in minutes (1440 = 24 hours of inactivity).
SESSION_LIFETIME=1440
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_DOMAIN=.ladill.com
LADILL_SSO_CLIENT_ID=
@@ -39,50 +33,10 @@ PAY_API_KEY_EVENTS=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_EVENTS=
MEET_API_URL=https://meet.ladill.com/api/service/v1
MEET_API_KEY_EVENTS=
MEET_WEBHOOK_SECRET_EVENTS=
# Ladill Mail calendar (sync events to the webmail utility calendar)
LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1
MAIL_API_KEY_EVENTS=
# Inbound service API (Meet calls Events)
EVENTS_API_KEY_MEET=
SMS_PLATFORM_API_URL=https://ladill.com/api
SMS_API_KEY_EVENTS=
SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms
SMS_DEFAULT_SENDER_ID=Ladill
# Transactional email (platform mailer — same pattern as Invoice)
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=noreply@ladill.com
MAIL_FROM_NAME=Ladill
# Platform suite messaging (zero-config — channel=suite; no Bird API key required)
# Monolith must have MESSAGING_SUITE_PLATFORM_CHANNEL=true and SMTP_API_KEY_EVENTS set.
SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp
SMTP_API_KEY_EVENTS=
SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp
EVENTS_SMTP_FROM=events@ladill.com
EVENTS_SMTP_FROM_NAME="Ladill Events"
MESSAGING_SETTINGS_URL=https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite
AFIA_ENABLED=true
AFIA_PRODUCT=events
AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
LINK_PUBLIC_DOMAIN=ladl.link
# Suite demo accounts (sales walkthroughs) — reset Events data on each SSO login
DEMO_ACCOUNTS_ENABLED=false
DEMO_RESET_ON_LOGIN=true
VITE_APP_NAME="${APP_NAME}"
+14 -16
View File
@@ -19,9 +19,8 @@ jobs:
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-events-${{ gitea.run_id }}
RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-events-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-events
steps:
- name: Checkout
@@ -39,17 +38,11 @@ jobs:
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Build frontend assets
- name: Setup Node.js
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [ -x "$NODE_ROOT/bin/npm" ]; then
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
else
if [ ! -x "$NODE_ROOT/bin/node" ]; then
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
@@ -59,12 +52,17 @@ jobs:
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
export PATH="$NODE_ROOT/bin:$PATH"
NPM_BIN="$NODE_ROOT/bin/npm"
fi
"$NPM_BIN" ci --no-audit --no-fund
"$NPM_BIN" run build
"$NODE_ROOT/bin/node" -v
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
@@ -80,7 +78,7 @@ jobs:
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}.tgz
LADILL_RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
-1
View File
@@ -24,4 +24,3 @@
Homestead.json
Homestead.yaml
Thumbs.db
storage/app/private/qr/
-368
View File
@@ -1,368 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Events\ProSubscription;
use App\Models\PaymentGatewaySetting;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrTeamMember;
use App\Models\User;
use App\Services\Events\SubscriptionService;
use App\Support\DemoWorld;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Seed plan-aware Events demo data for a suite demo identity.
*/
class DemoSeedCommand extends Command
{
protected $signature = 'demo:seed
{identity : Email or public_id of the local mirrored user}
{--plan=free : free|pro|enterprise}
{--reset : Remove prior demo data for this user before seeding}
{--public-id= : Platform public_id when creating a local mirror}
{--email= : Email when creating a local mirror}
{--name= : Display name when creating a local mirror}';
protected $description = 'Seed Events demo data for a suite demo account';
public function handle(SubscriptionService $subscriptions): int
{
$plan = strtolower((string) $this->option('plan'));
if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) {
$this->error('Plan must be free, pro, or enterprise.');
return self::FAILURE;
}
$user = $this->resolveUser();
if (! $user) {
return self::FAILURE;
}
if ($this->option('reset')) {
$this->resetDemoData($user);
$this->info('Reset demo data for '.$user->email);
}
$this->assignPlan($user, $plan, $subscriptions);
if ($this->hasDemoMarker($user) && ! $this->option('reset')) {
$this->info('Demo data already present; plan updated. Use --reset to reseed.');
$this->printSummary($user, $plan, $subscriptions);
return self::SUCCESS;
}
$this->seedEvents($user, $plan);
$this->printSummary($user, $plan, $subscriptions);
return self::SUCCESS;
}
private function resolveUser(): ?User
{
$identity = trim((string) $this->argument('identity'));
$publicIdOpt = trim((string) ($this->option('public-id') ?: ''));
$emailOpt = trim((string) ($this->option('email') ?: ''));
$nameOpt = trim((string) ($this->option('name') ?: ''));
$user = User::query()
->where('email', $identity)
->orWhere('public_id', $identity)
->first();
if ($user) {
return $user;
}
$publicId = $publicIdOpt !== '' ? $publicIdOpt : (Str::isUuid($identity) ? $identity : '');
$email = $emailOpt !== '' ? $emailOpt : (filter_var($identity, FILTER_VALIDATE_EMAIL) ? $identity : '');
if ($publicId === '' || $email === '') {
$this->error('Local user not found for "'.$identity.'". Pass --public-id and --email to create a mirror.');
return null;
}
return User::query()->updateOrCreate(
['public_id' => $publicId],
[
'email' => $email,
'name' => $nameOpt !== '' ? $nameOpt : Str::before($email, '@'),
'email_verified_at' => now(),
],
);
}
private function assignPlan(User $user, string $plan, SubscriptionService $subscriptions): void
{
if ($plan === 'free') {
ProSubscription::query()->where('user_id', $user->id)->delete();
return;
}
$planKey = $plan === 'enterprise'
? ProSubscription::PLAN_ENTERPRISE
: ProSubscription::PLAN_PRO;
ProSubscription::query()->updateOrCreate(
['user_id' => $user->id],
[
'plan' => $planKey,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $planKey === ProSubscription::PLAN_ENTERPRISE
? $subscriptions->enterprisePriceMinor()
: $subscriptions->priceMinor(),
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => now(),
'current_period_end' => now()->addYear(),
'last_charged_at' => null,
'canceled_at' => null,
'last_reference' => 'demo-seed-'.$planKey,
'last_error' => null,
],
);
}
private function hasDemoMarker(User $user): bool
{
return QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->where('short_code', 'like', $this->codePrefix($user).'%')
->exists();
}
private function codePrefix(User $user): string
{
return 'dm'.substr(md5((string) $user->public_id), 0, 8);
}
private function resetDemoData(User $user): void
{
DB::transaction(function () use ($user) {
PaymentGatewaySetting::query()->where('owner_ref', $user->public_id)->delete();
QrTeamMember::query()
->where('account_id', $user->id)
->orWhere('user_id', $user->id)
->delete();
$codes = QrCode::query()->where('user_id', $user->id)->pluck('id');
if ($codes->isNotEmpty()) {
QrEventRegistration::query()->whereIn('qr_code_id', $codes)->delete();
QrCode::query()->whereIn('id', $codes)->delete();
}
QrEventRegistration::query()->where('user_id', $user->id)->delete();
ProSubscription::query()->where('user_id', $user->id)->delete();
});
}
private function seedEvents(User $user, string $plan): void
{
$prefix = $this->codePrefix($user);
$paid = $plan !== 'free';
$eventCount = $paid ? ($plan === 'enterprise' ? 5 : 4) : 2;
$regsPerEvent = $paid ? ($plan === 'enterprise' ? 40 : 25) : 35;
$keynoteSpeaker = DemoWorld::personByKey('ama-mensah');
$breakoutSpeaker = DemoWorld::personByKey('kojo-darko');
$programmeId = null;
if ($paid) {
$programme = QrCode::query()->updateOrCreate(
['short_code' => $prefix.'-itn'],
[
'user_id' => $user->id,
'type' => QrCode::TYPE_ITINERARY,
'label' => 'Demo Summit Programme',
'payload' => [
'content' => [
'title' => 'Accra Tech Summit Programme',
'subtitle' => 'Two-day agenda',
'description' => 'Demo programme for sales walkthroughs.',
'event_date' => now()->addDays(14)->toDateString(),
'location' => 'Kempinski Accra',
'brand_color' => '#0F766E',
'days' => [[
'label' => 'Day 1',
'date' => now()->addDays(14)->toDateString(),
'items' => [
[
'ref' => 'day0-item0',
'time' => '09:00',
'title' => 'Opening keynote',
'description' => 'Welcome and product roadmap',
'location' => 'Main hall',
'host' => DemoWorld::fullName($keynoteSpeaker),
'host_speaker_email' => $keynoteSpeaker['email'],
],
[
'ref' => 'day0-item1',
'time' => '11:00',
'title' => 'Breakout: ticketing',
'description' => 'Paid tiers and badges',
'location' => 'Room B',
'host' => DemoWorld::fullName($breakoutSpeaker),
'host_speaker_email' => $breakoutSpeaker['email'],
],
],
]],
],
'style' => [],
],
'is_active' => true,
],
);
$programmeId = $programme->id;
}
for ($i = 1; $i <= $eventCount; $i++) {
$mode = $paid ? 'ticketing' : 'free';
$tiers = $paid
? [
['name' => 'General', 'price' => 150, 'capacity' => 200],
['name' => 'VIP', 'price' => 450, 'capacity' => 40],
]
: [['name' => 'Registration', 'price' => 0, 'capacity' => 100]];
$event = QrCode::query()->updateOrCreate(
['short_code' => $prefix.'-e'.$i],
[
'user_id' => $user->id,
'type' => QrCode::TYPE_EVENT,
'label' => $paid ? 'Demo Paid Event '.$i : 'Demo Free Event '.$i,
'payload' => [
'content' => [
'name' => $paid ? 'Accra Business Forum '.$i : 'Community Meetup '.$i,
'tagline' => $paid ? 'Ticketed demo event' : 'Free RSVP demo event',
'description' => 'Seeded by demo:seed for sales walkthroughs.',
'location' => 'Accra, Ghana',
'starts_at' => now()->addDays(7 + $i)->toIso8601String(),
'ends_at' => now()->addDays(7 + $i)->addHours(6)->toIso8601String(),
'organizer' => DemoWorld::business($plan)['name'],
'currency' => 'GHS',
'mode' => $mode,
'format' => $i === 1 ? 'in_person' : 'hybrid',
'tiers' => $tiers,
'badge_fields' => ['company', 'role'],
'badge_size' => 'standard',
'accepts_payment' => $paid,
'registration_open' => true,
'programme_qr_id' => $programmeId,
'speakers' => $paid ? [
[
'name' => DemoWorld::fullName($keynoteSpeaker),
'email' => $keynoteSpeaker['email'],
'role' => 'Keynote',
],
] : [],
],
'style' => [],
],
'is_active' => true,
],
);
$this->seedRegistrations($user, $event, $regsPerEvent, $paid, $prefix, $i);
}
if ($paid) {
PaymentGatewaySetting::query()->updateOrCreate(
['owner_ref' => $user->public_id],
[
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'public_key' => 'pk_test_demo_events',
'secret_key' => 'sk_test_demo_events',
'is_active' => true,
'metadata' => ['demo' => true],
],
);
}
}
private function seedRegistrations(
User $user,
QrCode $event,
int $count,
bool $paid,
string $prefix,
int $eventIndex,
): void {
$existing = QrEventRegistration::query()
->where('qr_code_id', $event->id)
->where('reference', 'like', $prefix.'%')
->count();
if ($existing >= $count) {
return;
}
$people = DemoWorld::people();
for ($n = $existing + 1; $n <= $count; $n++) {
$tier = $paid ? ($n % 5 === 0 ? 'VIP' : 'General') : 'Registration';
$amount = $paid ? ($tier === 'VIP' ? 45000 : 15000) : 0;
$ref = sprintf('%s-r%02d-%03d', $prefix, $eventIndex, $n);
$badge = strtoupper(substr($prefix, 2, 4).sprintf('%02d%03d', $eventIndex, $n));
$person = $people[($n - 1) % count($people)];
QrEventRegistration::query()->updateOrCreate(
['reference' => $ref],
[
'qr_code_id' => $event->id,
'user_id' => $user->id,
'badge_code' => substr($badge, 0, 16),
'tier_name' => $tier,
'amount_minor' => $amount,
'currency' => 'GHS',
'attendee_name' => DemoWorld::fullName($person),
'attendee_email' => $person['email'],
'attendee_phone' => $person['phone'],
'badge_fields' => [
'company' => DemoWorld::companyNameForPerson($person),
'role' => $tier === 'VIP' ? 'Executive' : 'Guest',
],
'status' => QrEventRegistration::STATUS_CONFIRMED,
'payment_reference' => $paid ? 'demo-pay-'.$ref : null,
'paid_at' => $paid ? now()->subDays(max(1, 30 - $n)) : null,
'checked_in_at' => $n % 4 === 0 ? now()->subHours($n) : null,
'metadata' => ['demo' => true],
],
);
}
}
private function printSummary(User $user, string $plan, SubscriptionService $subscriptions): void
{
$events = QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->count();
$programmes = QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_ITINERARY)
->count();
$regs = QrEventRegistration::query()->where('user_id', $user->id)->count();
$this->table(
['Field', 'Value'],
[
['email', $user->email],
['public_id', $user->public_id],
['requested_plan', $plan],
['effective_plan', $subscriptions->planKey($user)],
['live_events', (string) $events],
['programmes', (string) $programmes],
['registrations', (string) $regs],
],
);
}
}
@@ -1,27 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Events\ProSubscription;
use App\Services\Events\SubscriptionService;
use Illuminate\Console\Command;
class RenewProSubscriptionsCommand extends Command
{
protected $signature = 'events:pro-renew';
protected $description = 'Charge the Ladill wallet for due Events Pro subscriptions.';
public function handle(SubscriptionService $subscriptions): int
{
$due = ProSubscription::where('status', ProSubscription::STATUS_ACTIVE)
->where('auto_renew', true)
->where('current_period_end', '<=', now())
->get();
$this->info("Renewing {$due->count()} due subscription(s).");
$due->each(fn (ProSubscription $sub) => $subscriptions->renewIfDue($sub));
return self::SUCCESS;
}
}
@@ -1,39 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Meet\MeetLifecycleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeetWebhookController extends Controller
{
public function __invoke(Request $request, MeetLifecycleService $lifecycle): JsonResponse
{
$secret = (string) config('meet.webhook_secret', '');
if ($secret === '') {
return response()->json(['error' => 'Webhook not configured.'], 503);
}
$signature = (string) $request->header('X-Ladill-Meet-Signature', '');
$body = $request->getContent();
$expected = hash_hmac('sha256', $body, $secret);
if ($signature === '' || ! hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature.'], 401);
}
/** @var array<string, mixed> $payload */
$payload = $request->json()->all();
$event = (string) ($payload['event'] ?? '');
if ($event === '') {
return response()->json(['error' => 'Missing event.'], 422);
}
$lifecycle->handle($event, $payload);
return response()->json(['ok' => true]);
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CustomDomain\CustomDomainService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Signed completion callback from Ladill Domains' SSL service. Flips a custom
* domain to live (or failed) once the certificate is issued. Auth is the HMAC
* signature over the raw body (shared SSL_CALLBACK_SECRET), not a session.
*/
class SslCallbackController extends Controller
{
public function __invoke(Request $request, CustomDomainService $service): JsonResponse
{
$secret = (string) config('customdomain.callback_secret');
$body = $request->getContent();
$signature = (string) $request->header('X-Ladill-Signature', '');
if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) {
return response()->json(['error' => 'Invalid signature.'], 401);
}
$payload = json_decode($body, true);
$data = (array) ($payload['data'] ?? []);
$host = (string) ($data['host'] ?? '');
if ($host === '') {
return response()->json(['error' => 'Missing host.'], 422);
}
$service->applyCallback(
$host,
(string) ($data['status'] ?? 'failed'),
$data['expires_at'] ?? null,
$data['last_error'] ?? null,
);
return response()->json(['status' => 'ok']);
}
}
@@ -1,91 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Meet\EventMeetRoomLinkService;
use App\Services\Meet\EventRegistrationVerifyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceMeetController extends Controller
{
public function __construct(private readonly EventMeetRoomLinkService $links) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
return response()->json([
'events' => $this->links->linkableEvents($owner),
]);
}
public function linkRoom(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'meet_room_uuid' => ['required', 'string', 'max:64'],
'meet_room_type' => ['required', 'string', 'in:town_hall,webinar'],
'join_url' => ['nullable', 'string', 'max:2048'],
'title' => ['nullable', 'string', 'max:255'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'virtual_session_id' => ['nullable', 'string', 'max:64'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = $this->links->linkRoom($owner, $event, $validated);
return response()->json(['event' => $result]);
}
public function verifyRegistration(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'badge_code' => ['required', 'string', 'max:16'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = app(EventRegistrationVerifyService::class)->verifyBadge($event, $validated['badge_code']);
if (! $result) {
return response()->json(['message' => 'Badge not found.'], 404);
}
return response()->json(['registration' => $result]);
}
public function verifySpeaker(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'speaker_token' => ['required', 'string', 'max:128'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = app(\App\Services\Events\EventSpeakerInviteService::class)
->verifyToken($event, $validated['speaker_token']);
if (! $result) {
return response()->json(['message' => 'Speaker invite not found.'], 404);
}
return response()->json(['speaker' => $result]);
}
}
@@ -88,7 +88,10 @@ class SsoLoginController extends Controller
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
@@ -123,8 +126,6 @@ class SsoLoginController extends Controller
Auth::login($user, remember: true);
$request->session()->regenerate();
app(\App\Services\Events\DemoLoginReseeder::class)->maybeResetAfterLogin($user);
return $this->finishCallback($request, $intended, null, $popup);
}
@@ -140,18 +141,6 @@ class SsoLoginController extends Controller
return redirect()->away($this->defaultSignedOutUrl());
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
@@ -5,27 +5,27 @@ namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Events\EventCommsService;
use App\Notifications\EventProgrammeSharedNotification;
use App\Services\Billing\SmsService;
use App\Support\Events\EventBadgeZpl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Notification;
use Illuminate\View\View;
class AttendeeController extends Controller
{
public function __construct(
private readonly EventCommsService $comms,
) {}
public function __construct(private readonly SmsService $sms) {}
public function hub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get()
->map(function (QrCode $event) {
@@ -41,15 +41,18 @@ class AttendeeController extends Controller
return view('events.attendees-hub', [
'events' => $events,
'search' => $search,
]);
}
public function badgesHub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get()
->map(function (QrCode $event) {
@@ -62,6 +65,7 @@ class AttendeeController extends Controller
return view('events.badges-hub', [
'events' => $events,
'search' => $search,
]);
}
@@ -127,24 +131,7 @@ class AttendeeController extends Controller
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
];
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))->filter(fn ($s) => is_array($s));
if (in_array($format, ['virtual', 'hybrid'], true) && $virtualSessions->isNotEmpty()) {
$stats['virtual_sessions'] = $virtualSessions->count();
$stats['virtual_live'] = $virtualSessions->where('meet_status', 'live')->count();
$stats['virtual_ended'] = $virtualSessions->where('meet_status', 'ended')->count();
$stats['virtual_recordings'] = $virtualSessions->filter(fn ($s) => ! empty($s['recording_url']))->count();
}
$programme = $this->programmeFor($event);
$joinUrl = $this->comms->primaryJoinUrl($event);
$commsModes = array_values(array_filter([
$programme ? EventCommsService::MODE_PROGRAMME : null,
$joinUrl !== '' ? EventCommsService::MODE_JOIN : null,
($programme && $joinUrl !== '') ? EventCommsService::MODE_BOTH : null,
]));
return view('events.attendees', [
'qrCode' => $event,
@@ -152,67 +139,54 @@ class AttendeeController extends Controller
'registrations' => $registrations,
'stats' => $stats,
'programme' => $programme,
'commsModes' => $commsModes,
'defaultCommsMode' => $commsModes[0] ?? EventCommsService::MODE_PROGRAMME,
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
]);
}
public function previewComms(QrCode $event, Request $request): JsonResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$mode = (string) $request->query('mode', EventCommsService::MODE_PROGRAMME);
abort_unless(in_array($mode, [EventCommsService::MODE_PROGRAMME, EventCommsService::MODE_JOIN, EventCommsService::MODE_BOTH], true), 422);
return response()->json($this->comms->preview($event, $mode));
}
public function shareComms(QrCode $event, Request $request): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'mode' => ['required', Rule::in([
EventCommsService::MODE_PROGRAMME,
EventCommsService::MODE_JOIN,
EventCommsService::MODE_BOTH,
])],
]);
$preview = $this->comms->preview($event, $validated['mode']);
if ($preview['integrations_error'] ?? null) {
return back()->with('error', $preview['integrations_error']);
}
if ($preview['recipients'] === 0) {
return back()->with('error', 'No confirmed attendees to message yet.');
}
$result = $this->comms->send($event, $validated['mode']);
if ($result['emailed'] === 0 && $result['texted'] === 0) {
return back()->with('error', 'No messages were sent — check that attendees have email or phone on file.');
}
return back()->with('success', sprintf(
'Sent — %d email(s), %d SMS, %d skipped.',
$result['emailed'],
$result['texted'],
$result['skipped'],
));
}
/** @deprecated Use shareComms with mode=programme */
public function shareProgramme(QrCode $event): RedirectResponse
{
request()->merge(['mode' => EventCommsService::MODE_PROGRAMME]);
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
return $this->shareComms($event, request());
$programme = $this->programmeFor($event);
if (! $programme) {
return back()->with('error', 'No programme outline is attached to this event yet.');
}
$recipients = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme->publicUrl();
$emailed = 0;
$texted = 0;
foreach ($recipients as $reg) {
if ($reg->attendee_email) {
Notification::route('mail', $reg->attendee_email)
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
$emailed++;
}
if ($reg->attendee_phone) {
$this->sms->send(
$reg->attendee_phone,
sprintf(
'Hi %s, the programme for %s is here: %s',
explode(' ', $reg->attendee_name ?? 'there')[0],
$eventName,
$programmeUrl
)
);
$texted++;
}
}
if ($emailed === 0 && $texted === 0) {
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
}
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
}
private function programmeFor(QrCode $event): ?QrCode
@@ -1,68 +0,0 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\CustomDomain;
use App\Models\QrCode;
use App\Services\CustomDomain\CustomDomainService;
use App\Services\CustomDomain\DomainsSslClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CustomDomainController extends Controller
{
public function __construct(
private readonly CustomDomainService $service,
private readonly DomainsSslClient $domains,
) {}
public function store(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($this->service->enabled(), 404);
$data = $request->validate([
'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'],
'include_www' => ['nullable', 'boolean'],
]);
$host = strtolower(preg_replace('/^www\./', '', trim($data['host'])));
if (CustomDomain::where('host', $host)->exists()) {
return back()->withErrors(['host' => 'That domain is already connected.']);
}
$this->service->attach($event, $host, (bool) ($data['include_www'] ?? true));
// Surface this domain in Ladill Domains' "My Domains" (best-effort).
$this->domains->registerConnected(
$host,
(string) $request->user()->public_id,
'Event: '.$event->label,
route('events.show', $event),
);
return back()->with('success', "Domain added. Point an A record for {$host} (and www) to ".config('customdomain.server_ip').', then click Verify.');
}
public function verify(Request $request, CustomDomain $customDomain): RedirectResponse
{
$this->authorize('update', $customDomain->qrCode);
[$ok, $message] = $this->service->verifyAndProvision($customDomain);
return back()->with($ok ? 'success' : 'error', $message);
}
public function destroy(Request $request, CustomDomain $customDomain): RedirectResponse
{
$this->authorize('update', $customDomain->qrCode);
$host = $customDomain->host;
$customDomain->delete();
$this->domains->removeConnected($host);
return back()->with('success', 'Custom domain removed.');
}
}
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -15,10 +14,7 @@ use Throwable;
class OverviewController extends Controller
{
public function __construct(
private BillingClient $billing,
private SubscriptionService $subscriptions,
) {}
public function __construct(private BillingClient $billing) {}
public function index(Request $request): View
{
@@ -87,7 +83,6 @@ class OverviewController extends Controller
'recentEvents' => $recentEvents,
'programmeCount' => $programmeCount,
'balanceMinor' => $balanceMinor,
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($account),
]);
}
}
@@ -6,24 +6,18 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Identity\IdentityClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(
private BillingClient $billing,
private IdentityClient $identity,
) {}
public function __construct(private BillingClient $billing) {}
public function index(): View
{
$account = ladill_account();
$eventIds = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->pluck('id');
@@ -31,24 +25,9 @@ class PayoutsController extends Controller
$revenueMinor = (int) QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('amount_minor', '>', 0)
->get()
->sum(fn (QrEventRegistration $r) => $r->netAmountMinor());
$transactions = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('amount_minor', '>', 0)
->with('qrCode:id,label,payload')
->orderByDesc('paid_at')
->orderByDesc('id')
->limit(50)
->get();
->sum('amount_minor');
$balanceMinor = 0;
$payoutAccount = null;
$withdrawals = [];
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
@@ -58,68 +37,9 @@ class PayoutsController extends Controller
]);
}
try {
$payoutAccount = $this->identity->payoutAccount($account->public_id);
$withdrawals = $this->identity->withdrawals($account->public_id);
} catch (Throwable $e) {
Log::warning('Events payouts could not load payout account or withdrawals', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'balanceGhs' => round($balanceMinor / 100, 2),
'transactions' => $transactions,
'payoutAccount' => $payoutAccount,
'withdrawals' => $withdrawals,
]);
}
public function banks(Request $request): JsonResponse
{
$type = $request->query('type') === 'bank_account' ? 'bank' : 'mobile_money';
try {
return response()->json(['data' => $this->identity->banks($type)]);
} catch (Throwable $e) {
Log::warning('Events payouts could not load banks', ['error' => $e->getMessage()]);
return response()->json(['data' => []]);
}
}
public function updatePayoutAccount(Request $request): RedirectResponse
{
$data = $request->validate([
'account_type' => ['required', 'in:mobile_money,bank_account'],
'account_name' => ['required', 'string', 'max:200'],
'account_number' => ['required', 'string', 'max:30'],
'bank_code' => ['required', 'string', 'max:30'],
'bank_name' => ['required', 'string', 'max:200'],
'currency' => ['sometimes', 'string', 'size:3'],
]);
$data['currency'] = $data['currency'] ?? 'GHS';
$this->identity->updatePayoutAccount(ladill_account()->public_id, $data);
return redirect()
->route('events.payouts')
->with('success', 'Payout account saved.');
}
public function withdraw(Request $request): RedirectResponse
{
$data = $request->validate([
'amount' => ['required', 'numeric', 'min:1', 'max:50000'],
]);
$this->identity->withdraw(ladill_account()->public_id, (float) $data['amount']);
return redirect()
->route('events.payouts')
->with('success', 'Withdrawal request submitted. Funds are sent to your payout account once processed.');
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* Paid Events plans are retired. Routes remain so old bookmarks and emails
* do not 404; every action redirects to the dashboard.
*/
class ProController extends Controller
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function index(Request $request): RedirectResponse
{
return $this->retired();
}
public function subscribe(Request $request): RedirectResponse
{
return $this->retired();
}
public function subscribeEnterprise(Request $request): RedirectResponse
{
return $this->retired();
}
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
{
return $this->retired();
}
public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse
{
return $this->retired();
}
public function cancel(Request $request): RedirectResponse
{
return $this->retired();
}
private function retired(): RedirectResponse
{
return redirect()->route('events.dashboard')
->with('success', 'Ladill Events is free — paid subscriptions are no longer offered. Ticket payments use Ladill Pay with the standard platform fee.');
}
}
@@ -3,45 +3,22 @@
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrWallet;
use App\Services\Qr\QrCodeManagerService;
use App\Models\QrCode;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProgrammeController extends Controller
{
public function __construct(private QrCodeManagerService $manager) {}
public function create(): View
{
return view('events.programme-create', [
'pricePerQr' => QrWallet::pricePerQr(),
]);
}
public function index(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$programmes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::programmeTypes())
->latest()
->get();
$totalSessions = $programmes->sum(function ($programme) {
$days = (array) ($programme->content()['days'] ?? []);
return collect($days)->sum(fn ($day) => count((array) ($day['items'] ?? [])));
});
return view('events.programmes', [
'programmes' => $programmes,
'wallet' => $wallet,
'totalSessions' => $totalSessions,
'pricePerQr' => QrWallet::pricePerQr(),
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
return view('events.programmes', compact('programmes'));
}
}
@@ -1,102 +0,0 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Events\EventSpeakerInviteService;
use App\Services\Events\EventSpeakerService;
use App\Services\Qr\QrCodeManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpeakerController extends Controller
{
public function __construct(
private readonly EventSpeakerService $speakers,
private readonly EventSpeakerInviteService $invites,
private readonly QrCodeManagerService $manager,
) {}
public function hub(Request $request): View
{
$account = ladill_account();
$events = $this->speakers->eventsForAccount($account->id)
->map(function (QrCode $event) {
$event->speaker_count = count($this->speakers->rosterFor($event));
return $event;
});
return view('events.speakers-hub', [
'events' => $events,
]);
}
public function index(QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$roster = $this->invites->rosterWithInviteState($event);
return view('events.speakers', [
'qrCode' => $event,
'roster' => $roster,
'programmeAssignments' => $this->speakers->programmeAssignments($event),
'programme' => $this->speakers->linkedProgramme($event),
'canManualInvite' => collect($roster)->mapWithKeys(fn ($speaker) => [
$speaker['email'] => $this->invites->canSendManualInvite($event, $speaker),
])->all(),
]);
}
public function update(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'speakers' => ['nullable', 'array'],
'speakers.*.name' => ['nullable', 'string', 'max:120'],
'speakers.*.email' => ['required_with:speakers.*.name', 'email', 'max:190'],
'speakers.*.role' => ['nullable', 'string', 'max:80'],
'speakers.*.bio' => ['nullable', 'string', 'max:500'],
]);
try {
$this->manager->update($event, [
'speakers' => $validated['speakers'] ?? [],
]);
} catch (\RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()
->route('speakers.show', $event)
->with('success', 'Speaker roster saved.');
}
public function sendInvite(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'email' => ['required', 'email', 'max:190'],
]);
$owner = ladill_account() ?? $event->user;
abort_unless($owner, 500);
$result = $this->invites->sendManualInvite($event, $owner, $validated['email']);
if (! $result['ok']) {
return back()->with('error', $result['error']);
}
return back()->with('success', 'Speaker invitation sent.');
}
}
@@ -1,60 +0,0 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Services\Events\EventProgrammeService;
use App\Services\Events\EventSpeakerInviteService;
use Illuminate\View\View;
class SpeakerPortalController extends Controller
{
public function __construct(
private readonly EventSpeakerInviteService $invites,
private readonly EventProgrammeService $programmes,
) {}
public function show(string $shortCode, string $token): View
{
$resolved = $this->invites->resolvePortal($shortCode, $token);
abort_unless($resolved, 404);
$event = $resolved['event'];
$speaker = $resolved['speaker'];
$content = $event->content();
$programme = null;
$programmeSnapshot = null;
$programmeId = (int) ($content['programme_qr_id'] ?? 0);
if ($programmeId > 0) {
$programme = \App\Models\QrCode::query()
->where('id', $programmeId)
->where('type', \App\Models\QrCode::TYPE_ITINERARY)
->first();
$programmeSnapshot = $this->programmes->snapshot($programme);
}
$token = (string) ($speaker['invite_token'] ?? '');
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))
->filter(fn ($session) => is_array($session) && trim((string) ($session['join_url'] ?? '')) !== '')
->map(function (array $session) use ($event, $token) {
return [
'title' => trim((string) ($session['title'] ?? 'Virtual session')),
'type' => in_array($session['meet_room_type'] ?? '', ['webinar'], true) ? 'Webinar' : 'Conference',
'scheduled_at' => $session['scheduled_at'] ?? null,
'join_url' => $this->invites->speakerJoinUrl($event, $session, $token),
];
})
->values()
->all();
return view('events.speaker-portal', [
'event' => $event,
'speaker' => $speaker,
'eventContent' => $content,
'programmeSnapshot' => $programmeSnapshot,
'assignments' => $this->invites->assignmentsForSpeaker($event, $speaker),
'virtualSessions' => $virtualSessions,
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProductLandingController extends Controller
{
public function show(Request $request): View|RedirectResponse
{
$landing = config('product_landing', []);
$variant = (string) ($landing['landing_variant'] ?? 'default');
$dashboardRoute = (string) ($landing['dashboard_route'] ?? 'login');
if ($variant === 'webmail') {
if ($request->session()->has('mb.email')) {
return redirect()->route($dashboardRoute);
}
return view('product.landing');
}
if (auth()->check()) {
return redirect()->route($dashboardRoute);
}
return view('product.landing');
}
}
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Events\EventRegistrationService;
use App\Support\LadillLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -30,10 +29,9 @@ class EventRegistrationController extends Controller
'tier' => ['required', 'string', 'max:80'],
'amount' => ['nullable', 'numeric', 'min:1', 'max:1000000'],
'attendee_name' => ['required', 'string', 'max:120'],
'attendee_email' => ['nullable', 'email', 'max:200'],
'attendee_phone' => ['required', 'string', 'max:30'],
'attendee_email' => ['required', 'email', 'max:200'],
'attendee_phone' => ['nullable', 'string', 'max:30'],
'badge_fields' => ['nullable', 'array'],
'meet_return' => ['nullable', 'string', 'max:2048'],
]);
try {
@@ -45,11 +43,11 @@ class EventRegistrationController extends Controller
return response()->json([
'paid' => $result['paid'],
'checkout_url' => $result['checkout_url'],
'access_code' => $result['access_code'] ?? null,
'public_key' => $result['public_key'] ?? null,
'callback_url' => $result['callback_url'] ?? null,
'badge_code' => $result['registration']->badge_code,
'success_url' => $this->confirmedUrl($qrCode, $result['registration'], $request->input('meet_return')),
'success_url' => route('qr.public.event.confirmed', [
'shortCode' => $qrCode->short_code,
'ref' => $result['registration']->reference,
]),
]);
}
@@ -58,55 +56,31 @@ class EventRegistrationController extends Controller
$reference = (string) $request->query('reference', '');
if ($reference === '') {
return view('public.payment-return', [
'redirect' => LadillLink::url($shortCode),
]);
return redirect('/q/' . $shortCode)->with('error', 'Missing payment reference.');
}
try {
$registration = $this->eventService->complete($reference);
return view('public.payment-return', [
'redirect' => $this->confirmedUrl($registration->qrCode, $registration),
return redirect()->route('qr.public.event.confirmed', [
'shortCode' => $shortCode,
'ref' => $registration->reference,
]);
} catch (\Throwable $e) {
return view('public.payment-return', [
'redirect' => LadillLink::url($shortCode),
]);
return redirect('/q/' . $shortCode)->with('order_error', 'Payment could not be verified. Reference: ' . $reference);
}
}
public function confirmed(Request $request, string $shortCode, string $ref): View
public function confirmed(string $shortCode, string $ref): View
{
$registration = QrEventRegistration::query()
->where('reference', $ref)
->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode))
->firstOrFail();
$meetReturn = (string) $request->query('meet_return', '');
if ($meetReturn === '') {
$meetReturn = (string) (($registration->metadata ?? [])['meet_return'] ?? '');
}
return view('public.qr.event-confirmed', [
'registration' => $registration,
'qrCode' => $registration->qrCode,
'meetReturn' => $meetReturn,
]);
}
private function confirmedUrl(QrCode $qrCode, QrEventRegistration $registration, ?string $meetReturn = null): string
{
$url = LadillLink::path($qrCode->short_code, 'registered/'.$registration->reference);
$meetReturn = trim((string) ($meetReturn ?? ''));
if ($meetReturn === '') {
$meetReturn = trim((string) (($registration->metadata ?? [])['meet_return'] ?? ''));
}
if ($meetReturn !== '') {
$url .= '?meet_return='.urlencode($meetReturn);
}
return $url;
}
}
@@ -36,7 +36,7 @@ class QrScanController extends Controller
}
if ($qrCode->isDocumentType()) {
return redirect()->away($qrCode->publicPath('view'));
return redirect()->route('qr.public.view', $shortCode);
}
if ($qrCode->isBookType()) {
@@ -44,10 +44,7 @@ class QrScanController extends Controller
}
if ($qrCode->usesLandingPage()) {
return view('public.qr.landing', [
'qrCode' => $qrCode,
'meetReturn' => trim((string) $request->query('meet_return', '')),
]);
return view('public.qr.landing', ['qrCode' => $qrCode]);
}
abort(404);
@@ -65,7 +62,7 @@ class QrScanController extends Controller
return view('public.qr.document-viewer', [
'qrCode' => $qrCode,
'fileUrl' => $qrCode->publicPath('file'),
'fileUrl' => route('qr.public.file', $shortCode),
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
]);
}
+1 -70
View File
@@ -3,29 +3,18 @@
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\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Events\SubscriptionService;
use App\Services\Payments\MerchantGatewayService;
use App\Support\AccountBranding;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
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,
private MerchantGatewayService $gateway,
private SubscriptionService $subscriptions,
) {}
public function __construct(private BillingClient $billing) {}
private function topupUrl(): string
{
@@ -86,13 +75,6 @@ class AccountController extends Controller
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'gateway' => $this->gateway->settingFor($account),
'canUsePaymentGateway' => $this->subscriptions->canUsePaymentGateway($account),
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
'suiteMessagingReady' => app(PlatformEmailClient::class)->isConfigured()
|| app(PlatformSmsClient::class)->isConfigured(),
]);
}
@@ -130,35 +112,11 @@ class AccountController extends Controller
'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
'gateway_provider' => ['nullable', Rule::in([
PaymentGatewaySetting::PROVIDER_PAYSTACK,
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
PaymentGatewaySetting::PROVIDER_HUBTEL,
'',
])],
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
'gateway_is_active' => ['nullable', 'boolean'],
]);
$eventDefaults = $data['event_defaults'] ?? [];
$eventDefaults['registration_open'] = $request->boolean('event_defaults.registration_open');
$settings = $account->getOrCreateQrSetting();
$logoPath = $settings->logo_path;
if ($request->boolean('remove_logo')) {
AccountBranding::deleteStoredLogo($settings);
$logoPath = null;
}
if ($request->hasFile('logo')) {
$logoPath = AccountBranding::storeLogo($settings, $request->file('logo'));
}
QrSetting::updateOrCreate(
['user_id' => $account->id],
[
@@ -169,36 +127,9 @@ class AccountController extends Controller
'notify_payouts' => $request->boolean('notify_payouts'),
'event_defaults' => QrSetting::sanitizeEventDefaults($eventDefaults),
'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null),
'logo_path' => $logoPath,
],
);
$provider = (string) ($data['gateway_provider'] ?? '');
if ($this->subscriptions->canUsePaymentGateway($account)) {
$existing = PaymentGatewaySetting::query()->firstOrNew([
'owner_ref' => $account->public_id,
]);
if ($provider === '' && ! $existing->exists) {
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
if ($provider !== '') {
$existing->provider = $provider;
}
$existing->is_active = $provider !== '' && $request->boolean('gateway_is_active');
if (filled($data['gateway_public_key'] ?? null)) {
$existing->public_key = $data['gateway_public_key'];
}
if (filled($data['gateway_secret_key'] ?? null)) {
$existing->secret_key = $data['gateway_secret_key'];
}
if (array_key_exists('gateway_webhook_secret', $data) && $data['gateway_webhook_secret'] !== null) {
$existing->webhook_secret = $data['gateway_webhook_secret'] !== ''
? $data['gateway_webhook_secret']
: null;
}
$existing->save();
}
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
@@ -1,105 +0,0 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IntegrationsController extends Controller
{
public function edit(MessagingCredentialsService $credentials): View
{
$account = ladill_account();
return view('qr.account.integrations', [
'account' => $account,
'credential' => $credentials->forOwner((string) $account->public_id),
'messagingSettingsUrl' => function_exists('ladill_account_url')
? ladill_account_url('/account/settings/messaging')
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
]);
}
public function saveSms(Request $request, MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$ownerRef = (string) $account->public_id;
$data = $request->validate([
'sms_api_key' => ['required', 'string', 'max:200'],
'sms_sender_id' => ['required', 'string', 'max:11'],
]);
$result = $credentials->validateAndSaveSms(
$ownerRef,
$data['sms_api_key'],
$data['sms_sender_id'],
);
if (! ($result['ok'] ?? false)) {
return back()->withInput()->with('error', $result['error'] ?? 'Could not save SMS credentials.');
}
return back()->with('success', 'SMS credentials saved.');
}
public function disconnectSms(MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$credentials->disconnectSms((string) $account->public_id);
return back()->with('success', 'SMS disconnected.');
}
public function saveBird(Request $request, MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$ownerRef = (string) $account->public_id;
$data = $request->validate([
'bird_api_key' => ['required', 'string', 'max:200'],
'bird_from_email' => ['required', 'email', 'max:255'],
'bird_from_name' => ['nullable', 'string', 'max:100'],
]);
$result = $credentials->validateAndSaveBird(
$ownerRef,
$data['bird_api_key'],
$data['bird_from_email'],
$data['bird_from_name'] ?? null,
);
if (! ($result['ok'] ?? false)) {
return back()->withInput()->with('error', $result['error'] ?? 'Could not save email credentials.');
}
return back()->with('success', 'Email credentials saved.');
}
public function disconnectBird(MessagingCredentialsService $credentials): RedirectResponse
{
$account = ladill_account();
$credentials->disconnectBird((string) $account->public_id);
return back()->with('success', 'Email disconnected.');
}
public function previewSenders(Request $request, CustomerSmsClient $sms): JsonResponse
{
$data = $request->validate([
'sms_api_key' => ['required', 'string', 'max:200'],
]);
$result = $sms->senders(trim($data['sms_api_key']));
if (! ($result['ok'] ?? false)) {
return response()->json(['error' => $result['error'] ?? 'Could not load senders.'], 422);
}
return response()->json($result['data'] ?? []);
}
}
+2 -63
View File
@@ -7,7 +7,6 @@ use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
@@ -38,7 +37,6 @@ class QrCodeController extends Controller
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
@@ -74,15 +72,8 @@ class QrCodeController extends Controller
]);
}
public function create(Request $request): View|RedirectResponse
public function create(Request $request): View
{
$account = ladill_account() ?? $request->user();
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
if ($account && ($requestedType === QrCode::TYPE_EVENT) && ! $this->subscriptions->canCreateEvent($account)) {
return redirect()->route('events.pro.index')
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
}
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrSettings = $account->getOrCreateQrSetting();
@@ -91,19 +82,6 @@ class QrCodeController extends Controller
$requestedType = QrCode::TYPE_EVENT;
}
if ($requestedType === QrCode::TYPE_EVENT) {
return view('events.create', [
'pricePerQr' => QrWallet::pricePerQr(),
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
]);
}
if ($requestedType === QrCode::TYPE_ITINERARY) {
return view('events.programme-create', [
'pricePerQr' => QrWallet::pricePerQr(),
]);
}
return view('qr-codes.create', [
'wallet' => $wallet,
'requestedType' => $requestedType,
@@ -135,13 +113,6 @@ class QrCodeController extends Controller
public function store(Request $request): RedirectResponse
{
$account = ladill_account() ?? $request->user();
$requestedType = (string) $request->input('type', QrCode::TYPE_EVENT);
if ($account && $requestedType === QrCode::TYPE_EVENT && ! $this->subscriptions->canCreateEvent($account)) {
return redirect()->route('events.pro.index')
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
}
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
@@ -235,31 +206,6 @@ class QrCodeController extends Controller
]);
}
$itineraryOptions = $qrCode->type === QrCode::TYPE_EVENT
? $account->qrCodes()
->where('type', QrCode::TYPE_ITINERARY)
->orderBy('label')
->get(['id', 'label'])
: collect();
if ($qrCode->type === QrCode::TYPE_EVENT) {
return view('events.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'itineraryOptions' => $itineraryOptions,
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
]);
}
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
return view('events.programme-show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'linkedEventSpeakers' => app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode),
]);
}
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
@@ -280,13 +226,6 @@ class QrCodeController extends Controller
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
'customDomainServerIp' => config('customdomain.server_ip'),
'itineraryOptions' => $itineraryOptions,
'linkedEventSpeakers' => $qrCode->type === QrCode::TYPE_ITINERARY
? app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode)
: [],
]);
}
@@ -374,7 +313,7 @@ class QrCodeController extends Controller
}
}
$png = $this->imageGenerator->renderPng(\App\Support\LadillLink::url($shortCode), $style);
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
if ($tempLogoPath) {
Storage::disk('qr')->delete($tempLogoPath);
@@ -1,40 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
@@ -1,33 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'events'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
@@ -1,73 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*
* Re-checks auth.ladill.com at most once per TTL not on every request.
* Client-side sso-keepalive still pings periodically between checks.
*/
class EnsurePlatformSession
{
private const VERIFY_TTL_SECONDS = 90;
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->connectTimeout(2)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
if ($response->successful()) {
$request->session()->put('platform_session_verified_at', time());
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Services\Events\SubscriptionService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePro
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function handle(Request $request, Closure $next): Response
{
// Paid Events plans are retired — all authenticated users have full access.
return $next($request);
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
* pages. The splash shows once per browser session while the app loads, then
* fades out masking the client-side boot so the app feels instant.
*/
class InjectBootSplash
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->isMethod('GET') || ! Auth::check()) {
return $response;
}
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
if (! is_string($content)
|| stripos($content, '<body') === false
|| str_contains($content, 'id="ladill-boot"')) {
return $response;
}
$splash = View::make('partials.boot-splash')->render();
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
if (is_string($content)) {
$response->setContent($content);
}
return $response;
}
}
@@ -1,94 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\LadillLink;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RedirectLegacyQrToLadillLink
{
/** @var list<string> */
private const STORAGE_ASSET_SUFFIXES = [
'event-logo',
'event-cover',
'itinerary-cover',
'business-logo',
'business-cover',
'menu-logo',
'menu-cover',
'church-logo',
'church-cover',
'book-cover',
'vcard-avatar',
'app-icon',
'file',
];
public function handle(Request $request, Closure $next): Response
{
if (! preg_match('#^q/[a-z0-9]#i', ltrim($request->path(), '/'))) {
return $next($request);
}
if (LadillLink::isInternalRequest($request)) {
return $next($request);
}
// Same-origin AJAX from Events-hosted pages must not 307 to ladl.link
// (cross-origin fetch has no CORS → checkout sheet never opens).
if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) {
return $next($request);
}
if ($this->servesStoredAsset($request) || $this->servesRegistrationTransaction($request)) {
return $next($request);
}
return LadillLink::legacyRedirect($request);
}
private function servesStoredAsset(Request $request): bool
{
$shortCode = $request->route('shortCode');
if (! is_string($shortCode) || $shortCode === '') {
return false;
}
$path = ltrim($request->path(), '/');
$prefix = 'q/'.$shortCode.'/';
if (! str_starts_with($path, $prefix)) {
return false;
}
$suffix = substr($path, strlen($prefix));
if (in_array($suffix, self::STORAGE_ASSET_SUFFIXES, true)) {
return true;
}
return (bool) preg_match('#^(image/\d+|item-image/\d+/\d+)$#', $suffix);
}
private function servesRegistrationTransaction(Request $request): bool
{
$shortCode = $request->route('shortCode');
if (! is_string($shortCode) || $shortCode === '') {
return false;
}
$path = ltrim($request->path(), '/');
$prefix = 'q/'.$shortCode.'/';
if (! str_starts_with($path, $prefix)) {
return false;
}
$suffix = substr($path, strlen($prefix));
return $suffix === 'register'
|| str_starts_with($suffix, 'register/')
|| str_starts_with($suffix, 'registered/')
|| str_starts_with($suffix, 'speaker/');
}
}
-56
View File
@@ -1,56 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* A customer's own domain (e.g. brand.com) pointed at one storefront (QrCode).
* Visiting the domain serves that storefront's public page. DNS is verified to
* resolve to the app, then Ladill Domains issues + installs the TLS cert.
*/
class CustomDomain extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'qr_code_id', 'user_id', 'host', 'include_www', 'status', 'ssl_status',
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
];
protected function casts(): array
{
return [
'include_www' => 'boolean',
'dns_verified_at' => 'datetime',
'ssl_issued_at' => 'datetime',
'ssl_expires_at' => 'datetime',
];
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isLive(): bool
{
return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE;
}
protected static function booted(): void
{
static::saving(function (CustomDomain $d) {
$d->host = strtolower(trim((string) $d->host, " \t\n\r\0\x0B./"));
$d->host = preg_replace('/^www\./', '', $d->host) ?: $d->host;
});
}
}
-49
View File
@@ -1,49 +0,0 @@
<?php
namespace App\Models\Events;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProSubscription extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PAST_DUE = 'past_due';
public const PLAN_PRO = 'pro';
public const PLAN_ENTERPRISE = 'enterprise';
protected $table = 'events_pro_subscriptions';
protected $fillable = [
'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew',
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
'last_reference', 'last_error',
];
protected $casts = [
'auto_renew' => 'boolean',
'price_minor' => 'integer',
'started_at' => 'datetime',
'current_period_end' => 'datetime',
'last_charged_at' => 'datetime',
'canceled_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function entitled(): bool
{
return $this->status !== self::STATUS_PAST_DUE
&& $this->current_period_end !== null
&& $this->current_period_end->isFuture();
}
}
-87
View File
@@ -1,87 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class MessagingCredential extends Model
{
public const STATUS_VALID = 'valid';
public const STATUS_INVALID = 'invalid';
protected $table = 'events_messaging_credentials';
protected $fillable = [
'owner_ref',
'sms_api_key_encrypted',
'sms_api_key_prefix',
'sms_sender_id',
'sms_status',
'sms_validated_at',
'sms_last_error',
'bird_api_key_encrypted',
'bird_api_key_prefix',
'bird_from_email',
'bird_from_name',
'bird_status',
'bird_validated_at',
'bird_last_error',
];
protected $hidden = [
'sms_api_key_encrypted',
'bird_api_key_encrypted',
];
protected $casts = [
'sms_validated_at' => 'datetime',
'bird_validated_at' => 'datetime',
];
public function hasValidSms(): bool
{
return $this->sms_status === self::STATUS_VALID
&& filled($this->sms_api_key_encrypted)
&& filled($this->sms_sender_id);
}
public function hasValidBird(): bool
{
return $this->bird_status === self::STATUS_VALID
&& filled($this->bird_api_key_encrypted)
&& filled($this->bird_from_email);
}
public function smsApiKey(): ?string
{
if (! filled($this->sms_api_key_encrypted)) {
return null;
}
try {
return Crypt::decryptString($this->sms_api_key_encrypted);
} catch (\Throwable) {
return null;
}
}
public function birdApiKey(): ?string
{
if (! filled($this->bird_api_key_encrypted)) {
return null;
}
try {
return Crypt::decryptString($this->bird_api_key_encrypted);
} catch (\Throwable) {
return null;
}
}
public static function encryptKey(string $plain): string
{
return Crypt::encryptString($plain);
}
}
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentGatewaySetting extends Model
{
public const PROVIDER_PAYSTACK = 'paystack';
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
public const PROVIDER_HUBTEL = 'hubtel';
protected $fillable = [
'owner_ref',
'provider',
'public_key',
'secret_key',
'webhook_secret',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'public_key' => 'encrypted',
'secret_key' => 'encrypted',
'webhook_secret' => 'encrypted',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
public function isConfigured(): bool
{
if (! $this->is_active) {
return false;
}
$public = trim((string) $this->public_key);
$secret = trim((string) $this->secret_key);
return match ($this->provider) {
self::PROVIDER_PAYSTACK => str_starts_with($public, 'pk_') && str_starts_with($secret, 'sk_'),
self::PROVIDER_FLUTTERWAVE, self::PROVIDER_HUBTEL => $public !== '' && $secret !== '',
default => false,
};
}
}
+13 -10
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use App\Support\LadillLink;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
@@ -86,19 +85,23 @@ class QrCode extends Model
public function publicUrl(): string
{
return LadillLink::url($this->short_code);
}
public function publicPath(string $suffix = ''): string
{
return $suffix === ''
? $this->publicUrl()
: LadillLink::path($this->short_code, $suffix);
return self::publicBaseUrl() . '/q/' . $this->short_code;
}
/**
* Base URL for public QR links. Always the short platform domain
* (ladill.com) never the signed-in account/product host so printed
* codes and shared links stay short and host-independent of where the QR
* was created.
*/
public static function publicBaseUrl(): string
{
return LadillLink::baseUrl();
$appUrl = (string) config('app.url');
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
$host = (string) config('app.platform_domain')
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
return $scheme . '://' . $host;
}
/**
-23
View File
@@ -55,29 +55,6 @@ class QrEventRegistration extends Model
return round($this->amount_minor / 100, 2);
}
public function platformFeeMinor(): int
{
$metadata = (array) ($this->metadata ?? []);
if (isset($metadata['ladill_pay']['platform_fee_minor'])) {
return (int) $metadata['ladill_pay']['platform_fee_minor'];
}
if (isset($metadata['platform_fee_ghs'])) {
return (int) round((float) $metadata['platform_fee_ghs'] * 100);
}
$mode = $this->qrCode?->content()['mode'] ?? 'ticketing';
$rate = $mode === 'contributions' ? 0.035 : 0.055;
return (int) round($this->amount_minor * $rate);
}
public function netAmountMinor(): int
{
return max(0, $this->amount_minor - $this->platformFeeMinor());
}
public function isPaid(): bool
{
return $this->amount_minor > 0;
-5
View File
@@ -20,7 +20,6 @@ class QrSetting extends Model
'default_type',
'default_style',
'event_defaults',
'logo_path',
];
protected $casts = [
@@ -43,7 +42,6 @@ class QrSetting extends Model
'organizer',
'registration_open',
'badge_fields',
'format',
];
}
@@ -144,9 +142,6 @@ class QrSetting extends Model
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
'format' => in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person',
];
}
+3 -12
View File
@@ -4,11 +4,10 @@ namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use App\Services\Events\SubscriptionService;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\View;
class AppServiceProvider extends ServiceProvider
{
@@ -20,17 +19,9 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
View::composer('partials.sidebar', function ($view) {
$account = function_exists('ladill_account') ? (ladill_account() ?? auth()->user()) : auth()->user();
$svc = app(SubscriptionService::class);
$view->with('isPro', $account ? $svc->isPro($account) : false);
$view->with('hasPaidPlan', $account ? $svc->hasPaidPlan($account) : false);
$view->with('isEnterprise', $account ? $svc->isEnterprise($account) : false);
});
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
$view->with(MobileTopbar::resolve());
});
}
}
+23 -15
View File
@@ -88,28 +88,36 @@ class AfiaService
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return $this->eventsSystemPrompt($ctx);
return match ((string) config('afia.product', 'qr')) {
'qr' => $this->qrSystemPrompt($ctx),
default => $this->qrSystemPrompt($ctx),
};
}
private function eventsSystemPrompt(string $ctx): string
private function qrSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill Events (events.ladill.com).
Help users create events, sell tickets, and manage attendees. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill Events does and where things live:
- Overview (Dashboard): upcoming events, ticket sales, recent attendees, and wallet balance.
- Events: create and manage events set details, date, venue, and ticket types/prices, then publish and share the event link or QR.
- Attendees: people holding tickets view them and check them in (scan their ticket QR at the door).
- Badges: design and print attendee badges.
- Programmes: build the event schedule/agenda (sessions, times, speakers).
- Payouts: ticket revenue lands in your Ladill wallet; request a withdrawal to your bank or mobile money from Payouts.
- Account, Wallet & Team: manage billing, balance, and teammates from the account area.
What Ladill QR Plus does and where things live:
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
- Dynamic codes: destination can change after printing the printed QR always points to ladill.com/q/{code}.
- Business QR: name, tagline, contact, hours, logo, cover, social links shown as a mobile landing page.
- WiFi QR: guests scan to join; network name and password are encoded in the code.
- PDF QR: hosts a PDF with optional download button on the viewer.
- Billing: each new code debits the Ladill wallet (account portal Wallet). Top up at account.ladill.com/wallet.
- Team: invite teammates to manage codes together (sidebar Team).
- Developers: API tokens for programmatic code creation (sidebar Developers).
Rules:
- Only answer questions about Ladill Events events, tickets, attendees, check-in, badges, programmes, and payouts.
- If asked about domains, hosting, email, plain QR codes, storefronts, donations, or payments, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, ticket numbers, payout amounts, or wallet balances use the user context below or tell them where to check.
- Only answer questions about Ladill QR Plus code types, styling, downloads, scans, wallet billing, team, and API.
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, short codes, or wallet balances use the user context below or tell them where to check.
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
Current user context:
{$ctx}
-148
View File
@@ -3,8 +3,6 @@
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Models\User;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
@@ -86,150 +84,4 @@ class BillingClient
return (array) $res->json();
}
public function configured(): bool
{
return $this->base() !== '' && $this->token() !== '';
}
/**
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
*/
public function charge(User $user, int $amountMinor, string $reference, string $description): array
{
if (! $this->configured()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
}
try {
$res = Http::withToken($this->token())
->acceptJson()->timeout(20)
->post($this->base().'/debit', [
'user' => $user->public_id,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'events'),
'source' => 'subscription',
'reference' => $reference,
'description' => $description,
]);
} catch (\Throwable $e) {
Log::warning('Events BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
}
if ($res->status() === 402) {
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
}
if ($res->failed()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
}
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string}
*/
public function initiatePlanCheckout(
User $user,
string $plan,
int $months,
int $amountMinor,
string $returnUrl,
array $metadata = [],
): array {
$res = Http::withToken($this->token())
->acceptJson()->timeout(15)
->post($this->base().'/plan-checkout', [
'user' => $user->public_id,
'app' => (string) config('billing.service', 'events'),
'plan' => $plan,
'months' => $months,
'amount_minor' => $amountMinor,
'return_url' => $returnUrl,
'metadata' => $metadata,
]);
$res->throw();
return [
'checkout_url' => (string) $res->json('checkout_url'),
'reference' => (string) $res->json('reference'),
];
}
/** @return array<string, mixed> */
public function verifyPlanCheckout(string $reference): array
{
$res = Http::withToken($this->token())
->acceptJson()->timeout(15)
->post($this->base().'/plan-checkout/verify', [
'reference' => $reference,
]);
if ($res->status() === 422) {
return ['paid' => false, 'error' => $res->json('error')];
}
$res->throw();
return $res->json();
}
/**
* Write suite messaging plan entitlement (SoT for included email/SMS allowances).
*
* @param array{period_starts_at?: string, period_ends_at?: string|null, source?: string, metadata?: array} $options
*/
public function syncSuiteEntitlement(
string $publicId,
string $plan,
string $status,
string $reference,
array $options = [],
): bool {
if (! $this->configured()) {
return false;
}
$app = (string) config('billing.service', 'events');
$starts = $options['period_starts_at'] ?? now()->toIso8601String();
$ends = array_key_exists('period_ends_at', $options)
? $options['period_ends_at']
: now()->addDays(30)->toIso8601String();
try {
$res = Http::withToken($this->token())
->acceptJson()
->timeout(15)
->post($this->base().'/suite-entitlements', array_filter([
'user' => $publicId,
'app' => $app,
'plan' => $plan,
'status' => $status,
'period_starts_at' => $starts,
'period_ends_at' => $ends,
'source' => $options['source'] ?? 'wallet_debit',
'reference' => $reference,
'metadata' => $options['metadata'] ?? null,
], static fn ($v) => $v !== null));
if ($res->failed()) {
Log::warning('Events suite entitlement sync failed', [
'user' => $publicId,
'status' => $res->status(),
'body' => $res->body(),
]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('Events suite entitlement sync error', ['error' => $e->getMessage()]);
return false;
}
}
}
@@ -1,119 +0,0 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Platform suite email (zero-config): POST /api/smtp/messages/send with channel=suite.
* No Bird API key or smtp_service_id required when platform messaging is enabled.
*/
class PlatformEmailClient
{
private ?string $lastError = null;
private ?string $lastErrorCode = null;
public function lastError(): ?string
{
return $this->lastError;
}
public function lastErrorCode(): ?string
{
return $this->lastErrorCode;
}
private function base(): string
{
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/');
}
private function token(): string
{
return (string) config('smtp.platform_api_key', '');
}
public function isConfigured(): bool
{
return $this->token() !== '';
}
public function messagingSettingsUrl(): string
{
return (string) config(
'smtp.messaging_settings_url',
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
);
}
public function send(
string $ownerPublicId,
string $to,
string $subject,
string $html,
?string $text = null,
?string $fromName = null,
?string $idempotencyKey = null,
): bool {
$this->lastError = null;
$this->lastErrorCode = null;
if (! $this->isConfigured()) {
$this->lastError = 'Outbound email is not configured on this Events instance.';
return false;
}
$key = $idempotencyKey ?: ('events-email-'.Str::uuid());
try {
$res = Http::withToken($this->token())
->withHeaders(['Idempotency-Key' => $key])
->acceptJson()
->timeout(30)
// from_name: pass the organizer/company display name. Do not default
// to EVENTS_SMTP_FROM_NAME ("Ladill Events") — attendee mail is from the customer.
->post($this->base().'/messages/send', array_filter([
'user' => $ownerPublicId,
'channel' => 'suite',
'from_name' => $fromName,
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
'reference' => $key,
], static fn ($v) => $v !== null && $v !== ''));
if ($res->status() === 402) {
$this->lastErrorCode = (string) ($res->json('error') ?: 'allowance_exceeded');
$this->lastError = (string) ($res->json('error') ?: 'Email allowance exceeded. Upgrade your plan or top up your wallet.');
Log::warning('Events suite email: allowance/balance', ['user' => $ownerPublicId, 'body' => $res->body()]);
return false;
}
if ($res->failed()) {
$code = (string) ($res->json('error') ?: 'send_failed');
$this->lastErrorCode = $code;
if ($code === 'messaging_mailbox_required') {
$this->lastError = 'Set a Ladill mailbox for suite email in Account → Messaging settings: '.$this->messagingSettingsUrl();
} else {
$this->lastError = (string) ($res->json('error') ?: 'The email could not be sent. Please try again.');
}
Log::warning('Events suite email failed', ['status' => $res->status(), 'body' => $res->body()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'The email could not be sent. Please try again.';
Log::warning('Events suite email error', ['error' => $e->getMessage()]);
return false;
}
}
}
@@ -1,90 +0,0 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Platform suite SMS (zero-config): POST /api/sms/messages/send with channel=suite.
* No customer API key or sms_service_id required when platform messaging is enabled.
*/
class PlatformSmsClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/');
}
private function token(): string
{
return (string) config('sms.platform_api_key', '');
}
public function isConfigured(): bool
{
return $this->token() !== '';
}
public function send(
string $ownerPublicId,
string $to,
string $message,
?string $senderId = null,
?string $idempotencyKey = null,
): bool {
$this->lastError = null;
if (! $this->isConfigured()) {
$this->lastError = 'SMS is not configured on this Events instance.';
return false;
}
$key = $idempotencyKey ?: ('events-sms-'.Str::uuid());
try {
$res = Http::withToken($this->token())
->withHeaders(['Idempotency-Key' => $key])
->acceptJson()
->timeout(30)
->post($this->base().'/sms/messages/send', array_filter([
'user' => $ownerPublicId,
'channel' => 'suite',
'to' => $to,
'text' => $message,
'sender_id' => $senderId ?? config('sms.default_sender_id'),
'reference' => $key,
], static fn ($v) => $v !== null && $v !== ''));
if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'SMS allowance exceeded. Upgrade your plan or top up your wallet.');
Log::warning('Events suite SMS: allowance/balance', ['user' => $ownerPublicId]);
return false;
}
if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: 'The SMS could not be sent.');
Log::warning('Events suite SMS failed', ['status' => $res->status(), 'body' => $res->body()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'Could not reach the SMS service.';
Log::warning('Events suite SMS error', ['error' => $e->getMessage()]);
return false;
}
}
}
+19 -78
View File
@@ -2,46 +2,23 @@
namespace App\Services\Billing;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Attendee SMS: prefer platform suite messaging (zero-config), fall back to
* product SMS API keys when suite is unavailable.
*/
class SmsService
{
private ?string $lastError = null;
public function __construct(
private readonly CustomerSmsClient $customer,
private readonly MessagingCredentialsService $credentials,
private readonly PlatformSmsClient $platformSms,
) {}
public function lastError(): ?string
public function send(string $to, string $message): void
{
return $this->lastError;
}
$apiKey = config('services.termii.api_key', '');
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
public function isConfiguredForOwner(string $ownerPublicId): bool
{
if ($this->platformSms->isConfigured()) {
return true;
if ($apiKey === '') {
return;
}
return $this->credentials->forOwner($ownerPublicId)->hasValidSms();
}
public function send(string $ownerPublicId, string $to, string $message): bool
{
$this->lastError = null;
$phone = preg_replace('/\D+/', '', $to);
if (strlen($phone) < 9) {
$this->lastError = 'Enter a valid phone number.';
return false;
return;
}
if (strlen($phone) === 9) {
@@ -50,53 +27,17 @@ class SmsService
$phone = '233'.substr($phone, 1);
}
$credential = $this->credentials->forOwner($ownerPublicId);
if ($this->platformSms->isConfigured()) {
$sender = $credential->sms_sender_id ?: null;
$sent = $this->platformSms->send(
$ownerPublicId,
$phone,
$message,
$sender ? (string) $sender : null,
);
if ($sent) {
return true;
}
$suiteError = $this->platformSms->lastError();
if ($this->tryCustomerSms($credential, $phone, $message)) {
return true;
}
$this->lastError = $suiteError ?: $this->lastError;
return false;
try {
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
'api_key' => $apiKey,
'to' => $phone,
'from' => $senderId,
'sms' => $message,
'type' => 'plain',
'channel' => 'dnd',
]);
} catch (\Throwable $e) {
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
}
return $this->tryCustomerSms($credential, $phone, $message);
}
private function tryCustomerSms(object $credential, string $phone, string $message): bool
{
if (! $credential->hasValidSms()) {
$this->lastError = 'SMS is not available (platform SMS key missing and no product SMS credentials).';
return false;
}
$apiKey = $credential->smsApiKey();
if (! $apiKey) {
$this->lastError = 'SMS credentials could not be decrypted. Reconnect SMS under Advanced keys.';
return false;
}
$sent = $this->customer->send($apiKey, $phone, $message, (string) $credential->sms_sender_id);
if (! $sent) {
$this->lastError = $this->customer->lastError() ?: 'The SMS could not be sent.';
}
return $sent;
}
}
@@ -1,103 +0,0 @@
<?php
namespace App\Services\CustomDomain;
use App\Models\CustomDomain;
use App\Models\QrCode;
use Illuminate\Support\Carbon;
class CustomDomainService
{
public function __construct(
private readonly DnsResolver $dns,
private readonly DomainsSslClient $ssl,
) {}
public function enabled(): bool
{
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
}
/** Attach a custom domain to a storefront (pending DNS). */
public function attach(QrCode $storefront, string $host, bool $includeWww = true): CustomDomain
{
return CustomDomain::create([
'qr_code_id' => $storefront->id,
'user_id' => $storefront->user_id,
'host' => $host,
'include_www' => $includeWww,
'status' => CustomDomain::STATUS_PENDING,
'ssl_status' => CustomDomain::STATUS_PENDING,
]);
}
/**
* Verify the domain's A record resolves to our app server, then ask Domains
* to issue the certificate. Returns [ok, message].
*
* @return array{0:bool,1:string}
*/
public function verifyAndProvision(CustomDomain $domain): array
{
$serverIp = (string) config('customdomain.server_ip');
$ips = $this->dns->aRecords($domain->host);
if (! in_array($serverIp, $ips, true)) {
$domain->forceFill([
'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').',
])->save();
return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.'];
}
$domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save();
$requested = $this->ssl->requestCertificate(
$domain->host,
$domain->include_www,
route('api.ssl-callback'),
);
if (! $requested) {
$domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save();
return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.'];
}
return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.'];
}
/** Apply a signed SSL completion callback from Ladill Domains. */
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
{
$domain = CustomDomain::where('host', strtolower(trim($host)))->first();
if (! $domain) {
return;
}
if ($status === 'active') {
$domain->forceFill([
'ssl_status' => CustomDomain::STATUS_ACTIVE,
'status' => CustomDomain::STATUS_ACTIVE,
'ssl_issued_at' => Carbon::now(),
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
'last_error' => null,
])->save();
} else {
$domain->forceFill([
'ssl_status' => CustomDomain::STATUS_FAILED,
'status' => CustomDomain::STATUS_FAILED,
'last_error' => $error ?: 'Certificate issuance failed.',
])->save();
}
}
/** Resolve an incoming host to a live storefront, if any. */
public function resolveStorefront(string $host): ?QrCode
{
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
$domain = CustomDomain::where('host', $host)->where('status', CustomDomain::STATUS_ACTIVE)->first();
return $domain?->qrCode;
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Services\CustomDomain;
class DnsResolver
{
/** @return array<int,string> the A-record IPs for a host */
public function aRecords(string $host): array
{
$records = @dns_get_record($host, DNS_A);
if (! is_array($records)) {
return [];
}
return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records)));
}
}
@@ -1,85 +0,0 @@
<?php
namespace App\Services\CustomDomain;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client for Ladill Domains' central SSL API. Requests/queries certificates
* for a custom domain on the shared app server (target = app).
*/
class DomainsSslClient
{
public function configured(): bool
{
return (string) config('customdomain.ssl_api_key', '') !== '';
}
/**
* Ask Domains to issue + install a certificate for $host, calling back when done.
*/
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
{
if (! $this->configured()) {
return false;
}
try {
$res = Http::withToken((string) config('customdomain.ssl_api_key'))
->acceptJson()->timeout(20)
->post(config('customdomain.ssl_api_url').'/ssl/certificates', [
'host' => $host,
'target' => 'app',
'include_www' => $includeWww,
'callback_url' => $callbackUrl,
'metadata' => ['nginx_include' => config('customdomain.nginx_include')],
]);
if ($res->failed()) {
Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]);
return false;
}
}
/** Register/update this domain in Ladill Domains' connected-domains registry (best-effort). */
public function registerConnected(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
{
if (! $this->configured() || $ownerPublicId === '') {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->post(config('customdomain.ssl_api_url').'/connected-domains', array_filter([
'host' => $host,
'owner_public_id' => $ownerPublicId,
'label' => $label,
'link_url' => $linkUrl,
]));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
/** Remove this domain from the connected-domains registry (best-effort). */
public function removeConnected(string $host): void
{
if (! $this->configured()) {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->delete(config('customdomain.ssl_api_url').'/connected-domains/'.urlencode($host));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected remove skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
}
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\User;
use App\Support\DemoWorld;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Reseed Events demo tenants after a fresh SSO login.
*
* Only owner demo accounts trigger a full reseed. Staff training logins
* share the owner tenant but never wipe mid-demo.
*/
class DemoLoginReseeder
{
public function maybeResetAfterLogin(User $user): void
{
if (! (bool) config('demo_accounts.enabled', false)
|| ! (bool) config('demo_accounts.reset_on_login', true)) {
return;
}
if (DemoWorld::isStaffDemoEmail($user->email)) {
return;
}
$plan = $this->planForEmail($user->email);
if ($plan === null) {
return;
}
$seedUser = $this->resolveSeedUser($user);
if (! $seedUser) {
return;
}
$lockKey = 'demo-reseed:events:'.$seedUser->public_id;
if (! Cache::add($lockKey, 1, 120)) {
return;
}
$identity = (string) ($seedUser->email ?: $seedUser->public_id);
dispatch(function () use ($identity, $plan, $lockKey) {
try {
Artisan::call('demo:seed', [
'identity' => $identity,
'--plan' => $plan,
'--reset' => true,
]);
} catch (\Throwable $e) {
Log::warning('demo_events_login_reseed_failed', [
'identity' => $identity,
'plan' => $plan,
'error' => $e->getMessage(),
]);
} finally {
Cache::forget($lockKey);
}
})->afterResponse();
}
private function planForEmail(?string $email): ?string
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
$accounts = (array) config('demo_accounts.accounts', []);
if (isset($accounts[$email])) {
return $accounts[$email];
}
$members = (array) config('demo_accounts.member_emails', []);
$ownerEmail = $members[$email] ?? null;
return $ownerEmail ? ($accounts[$ownerEmail] ?? null) : null;
}
private function resolveSeedUser(User $user): ?User
{
$email = strtolower((string) $user->email);
$ownerEmail = ((array) config('demo_accounts.member_emails', []))[$email] ?? null;
if (! $ownerEmail) {
return $user;
}
return User::query()->where('email', $ownerEmail)->first() ?? $user;
}
}
-208
View File
@@ -1,208 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Billing\SmsService;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Collection;
class EventCommsService
{
public const MODE_PROGRAMME = 'programme';
public const MODE_JOIN = 'join';
public const MODE_BOTH = 'both';
private const SMS_SEGMENT_CHARS = 160;
private const SMS_PRICE_GHS = 0.03;
private const EMAIL_PRICE_GHS = 0.0135;
public function __construct(
private readonly EventEmailService $email,
private readonly SmsService $sms,
private readonly MessagingCredentialsService $credentials,
private readonly EventProgrammeService $programmes,
private readonly PlatformEmailClient $platformEmail,
private readonly PlatformSmsClient $platformSms,
) {}
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool, integrations_error: ?string} */
public function preview(QrCode $event, string $mode): array
{
$regs = $this->confirmedRegistrations($event);
$emails = $regs->filter(fn ($r) => (bool) $r->attendee_email)->count();
$phones = $regs->filter(fn ($r) => (bool) $r->attendee_phone)->count();
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
$ownerRef = (string) $event->user->public_id;
$integrationsError = $this->integrationsError($event, $mode, $emails, $phones);
return [
'recipients' => $regs->count(),
'emails' => $emails,
'sms' => $phones,
'estimated_ghs' => $estimated,
'affordable' => $integrationsError === null,
'integrations_error' => $integrationsError,
];
}
/**
* @return array{emailed: int, texted: int, skipped: int}
*/
public function send(QrCode $event, string $mode): array
{
$preview = $this->preview($event, $mode);
abort_if($preview['integrations_error'] !== null, 422, $preview['integrations_error']);
$programme = $this->programmeFor($event);
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$ownerRef = (string) $event->user->public_id;
$organizerEmail = $event->user->email;
$organizerName = $event->user->name;
$emailed = 0;
$texted = 0;
$skipped = 0;
foreach ($this->confirmedRegistrations($event) as $reg) {
$sent = false;
if ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) {
if ($mode === self::MODE_BOTH) {
$sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
} else {
$sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
}
if ($sent) {
$emailed++;
}
} elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') {
$sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name, $organizerEmail, $organizerName);
if ($sent) {
$emailed++;
}
}
if ($reg->attendee_phone) {
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) {
$texted++;
$sent = true;
}
}
if (! $sent) {
$skipped++;
}
}
return compact('emailed', 'texted', 'skipped');
}
/** @return Collection<int, QrEventRegistration> */
private function confirmedRegistrations(QrCode $event): Collection
{
return $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
}
private function programmeFor(QrCode $event): ?QrCode
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
public function primaryJoinUrl(QrCode $event): string
{
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$url = trim((string) ($session['join_url'] ?? ''));
if ($url !== '') {
return $url;
}
}
return '';
}
private function smsBody(QrEventRegistration $reg, string $eventName, string $mode, ?string $programmeUrl, string $joinUrl): string
{
$first = explode(' ', $reg->attendee_name ?? 'there')[0];
return match ($mode) {
self::MODE_PROGRAMME => $programmeUrl
? sprintf('Hi %s, the programme for %s: %s', $first, $eventName, $programmeUrl)
: '',
self::MODE_JOIN => $joinUrl !== ''
? sprintf('Hi %s, join %s here: %s', $first, $eventName, $joinUrl)
: '',
self::MODE_BOTH => ($programmeUrl && $joinUrl !== '')
? sprintf('Hi %s, %s — programme: %s | join: %s', $first, $eventName, $programmeUrl, $joinUrl)
: ($joinUrl !== '' ? sprintf('Hi %s, join %s: %s', $first, $eventName, $joinUrl) : ''),
default => '',
};
}
private function estimateSmsSegments(QrCode $event, string $mode): int
{
$sample = $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$this->programmeFor($event)?->publicUrl(),
$this->primaryJoinUrl($event),
);
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS));
}
private function integrationsError(QrCode $event, string $mode, int $emails, int $phones): ?string
{
$ownerRef = (string) $event->user->public_id;
$credential = $this->credentials->forOwner($ownerRef);
$programmeUrl = $this->programmeFor($event)?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$needsEmail = $emails > 0 && (
(in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl)
|| ($mode === self::MODE_JOIN && $joinUrl !== '')
);
$needsSms = $phones > 0 && $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$programmeUrl,
$joinUrl,
) !== '';
$emailReady = $this->platformEmail->isConfigured() || $credential->hasValidBird();
$smsReady = $this->platformSms->isConfigured() || $credential->hasValidSms();
if ($needsEmail && ! $emailReady) {
return 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.';
}
if ($needsSms && ! $smsReady) {
return 'SMS is not available yet. Ensure platform SMS is configured on this Events instance, or connect a product SMS key under Advanced keys.';
}
return null;
}
}
-153
View File
@@ -1,153 +0,0 @@
<?php
namespace App\Services\Events;
use App\Support\AccountBranding;
use Illuminate\Support\Facades\View;
class EventEmailService
{
public function __construct(private readonly EventMailer $mailer) {}
public function lastError(): ?string
{
return $this->mailer->lastError();
}
public function sendProgrammeShare(
string $ownerPublicId,
string $to,
string $eventName,
string $programmeUrl,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Programme for '.$eventName,
'mail.notifications.event-programme',
compact('eventName', 'programmeUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendJoinLink(
string $ownerPublicId,
string $to,
string $eventName,
string $joinUrl,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Join '.$eventName,
'mail.notifications.event-join',
compact('eventName', 'joinUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendJoinAndProgramme(
string $ownerPublicId,
string $to,
string $eventName,
string $joinUrl,
string $programmeUrl,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
$eventName.' — programme & join link',
'mail.notifications.event-join-programme',
compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendRegistrationConfirmation(
string $ownerPublicId,
string $to,
string $eventName,
string $badgeCode,
?string $joinUrl = null,
?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Registration confirmed — '.$eventName,
'mail.notifications.event-registration-confirmed',
compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'),
$organizerEmail,
$organizerName,
);
}
public function sendSpeakerInvite(
string $ownerPublicId,
string $to,
string $eventName,
string $portalUrl,
?string $speakerName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Speaker invitation — '.$eventName,
'mail.notifications.event-speaker-invite',
[
'eventName' => $eventName,
'portalUrl' => $portalUrl,
'speakerName' => $speakerName,
],
$organizerEmail,
$organizerName,
);
}
/** @param array<string, mixed> $viewData */
private function send(
string $ownerPublicId,
string $to,
string $subject,
string $view,
array $viewData,
?string $replyToEmail = null,
?string $replyToName = null,
): bool {
$brandingSettings = AccountBranding::forOwnerRef($ownerPublicId);
$viewData['logoUrl'] = AccountBranding::emailLogoUrl($brandingSettings);
$viewData['companyName'] = AccountBranding::companyName($brandingSettings);
// From display: organizer name, else account/company branding — never "Ladill Events".
$fromName = trim((string) ($replyToName ?: $viewData['companyName'] ?? '')) ?: null;
$html = View::make($view, $viewData)->render();
$text = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html));
return $this->mailer->send(
$ownerPublicId,
$to,
$subject,
$html,
$text,
$replyToEmail,
$replyToName,
$fromName,
);
}
}
@@ -1,206 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Identity\IdentityClient;
use App\Services\Integrations\MailCalendarClient;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EventMailCalendarSyncService
{
public function __construct(
private readonly MailCalendarClient $mail,
private readonly IdentityClient $identity,
) {}
public function sync(QrCode $event, User $owner): QrCode
{
if ($event->type !== QrCode::TYPE_EVENT || ! $this->mail->isConfigured()) {
return $event;
}
$mailbox = $this->resolveMailbox($owner);
if ($mailbox === null) {
return $event;
}
$entries = $this->entriesFor($event);
$previous = (array) data_get($event->payload, 'calendar_sync.items', []);
$synced = [];
foreach ($entries as $entry) {
$result = $this->mail->upsertEvent(array_merge($entry, [
'mailbox_email' => $mailbox,
]));
if ($result && isset($result['id'])) {
$synced[$entry['external_ref']] = (int) $result['id'];
}
}
foreach ($previous as $externalRef => $eventId) {
if (! isset($synced[$externalRef]) && is_numeric($eventId)) {
$this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef);
}
}
if ($entries === [] && $previous !== []) {
foreach ($previous as $externalRef => $eventId) {
if (is_numeric($eventId)) {
$this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef);
}
}
}
$payload = (array) ($event->payload ?? []);
$payload['calendar_sync'] = ['items' => $synced];
$event->payload = $payload;
$event->save();
Log::info('Events mail calendar synced', [
'event_id' => $event->id,
'entries' => count($synced),
]);
return $event->fresh();
}
/**
* @return list<array<string, mixed>>
*/
public function entriesFor(QrCode $event): array
{
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
$sessions = array_values(array_filter(
(array) ($content['virtual_sessions'] ?? []),
fn ($session) => is_array($session) && trim((string) ($session['title'] ?? '')) !== '',
));
$entries = [];
if (in_array($format, ['virtual', 'hybrid'], true) && $sessions !== []) {
foreach ($sessions as $session) {
$entry = $this->sessionEntry($event, $content, $session);
if ($entry !== null) {
$entries[] = $entry;
}
}
if ($entries === [] && filled($content['starts_at'] ?? null)) {
$entries[] = $this->mainEventEntry($event, $content);
} elseif ($format === 'hybrid' && filled($content['starts_at'] ?? null)) {
$entries[] = $this->mainEventEntry($event, $content, ' (in person)');
}
return $entries;
}
if (filled($content['starts_at'] ?? null)) {
$entries[] = $this->mainEventEntry($event, $content);
}
return $entries;
}
/**
* @param array<string, mixed> $content
* @param array<string, mixed> $session
* @return array<string, mixed>|null
*/
private function sessionEntry(QrCode $event, array $content, array $session): ?array
{
$sessionId = trim((string) ($session['id'] ?? ''));
if ($sessionId === '') {
$sessionId = 'vs-'.Str::lower(Str::random(12));
}
$starts = $this->parseDate($session['scheduled_at'] ?? $content['starts_at'] ?? null);
if ($starts === null) {
return null;
}
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
$joinUrl = trim((string) ($session['join_url'] ?? ''));
$description = trim((string) ($content['description'] ?? ''));
return [
'external_ref' => $this->sessionExternalRef($event->id, $sessionId),
'title' => trim((string) ($session['title'] ?? $content['name'] ?? $event->label)),
'body' => $description !== '' ? $description : null,
'starts_at' => $starts->toIso8601String(),
'ends_at' => $starts->copy()->addMinutes($duration)->toIso8601String(),
'join_url' => $joinUrl !== '' ? $joinUrl : $event->publicUrl(),
];
}
/**
* @param array<string, mixed> $content
* @return array<string, mixed>
*/
private function mainEventEntry(QrCode $event, array $content, string $titleSuffix = ''): array
{
$starts = $this->parseDate($content['starts_at'] ?? null) ?? now();
$ends = $this->parseDate($content['ends_at'] ?? null) ?? $starts->copy()->addHours(2);
$name = trim((string) ($content['name'] ?? $event->label));
$location = trim((string) ($content['location'] ?? ''));
$body = trim((string) ($content['description'] ?? ''));
if ($location !== '') {
$body = $body !== '' ? $body."\n\n".$location : $location;
}
return [
'external_ref' => $this->eventExternalRef($event->id),
'title' => $name.$titleSuffix,
'body' => $body !== '' ? $body : null,
'starts_at' => $starts->toIso8601String(),
'ends_at' => $ends->toIso8601String(),
'join_url' => $event->publicUrl(),
];
}
private function eventExternalRef(int $eventId): string
{
return 'events:'.$eventId;
}
private function sessionExternalRef(int $eventId, string $sessionId): string
{
return 'events:'.$eventId.':session:'.$sessionId;
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value === null || $value === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
private function resolveMailbox(User $user): ?string
{
try {
$status = $this->identity->mailboxLinkStatus($user->public_id);
$linked = strtolower(trim((string) ($status['linked_mailbox'] ?? '')));
if (filter_var($linked, FILTER_VALIDATE_EMAIL)) {
return $linked;
}
} catch (\Throwable) {
// Fall back to the account email.
}
$email = strtolower(trim((string) $user->email));
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}
}
-132
View File
@@ -1,132 +0,0 @@
<?php
namespace App\Services\Events;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Messaging\CustomerEmailClient;
use App\Services\Messaging\MessagingCredentialsService;
use App\Support\AccountBranding;
/**
* Attendee/speaker email: prefer platform suite messaging (zero-config mailbox),
* fall back to customer Bird credentials when suite is unavailable.
*/
class EventMailer
{
private ?string $lastError = null;
public function __construct(
private readonly MessagingCredentialsService $credentials,
private readonly CustomerEmailClient $customerEmail,
private readonly PlatformEmailClient $platformEmail,
) {}
public function lastError(): ?string
{
return $this->lastError;
}
public function isConfiguredForOwner(string $ownerPublicId): bool
{
if ($this->platformEmail->isConfigured()) {
return true;
}
return $this->credentials->forOwner($ownerPublicId)->hasValidBird();
}
/** @deprecated Use isConfiguredForOwner() */
public function isConfigured(): bool
{
return $this->platformEmail->isConfigured();
}
public function send(
string $ownerPublicId,
string $to,
string $subject,
string $html,
?string $text = null,
?string $replyToEmail = null,
?string $replyToName = null,
?string $fromName = null,
): bool {
$this->lastError = null;
$displayName = $this->resolveFromName($ownerPublicId, $fromName, $replyToName);
if ($this->platformEmail->isConfigured()) {
$sent = $this->platformEmail->send($ownerPublicId, $to, $subject, $html, $text, $displayName);
if ($sent) {
return true;
}
// Suite unavailable (mailbox, allowance) — try Bird power-user path next.
$suiteError = $this->platformEmail->lastError();
if ($this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text, $displayName)) {
return true;
}
$this->lastError = $suiteError ?: $this->lastError;
return false;
}
return $this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text, $displayName);
}
private function resolveFromName(string $ownerPublicId, ?string $fromName, ?string $replyToName): ?string
{
foreach ([$fromName, $replyToName] as $candidate) {
$name = trim((string) $candidate);
if ($name !== '') {
return $name;
}
}
$settings = AccountBranding::forOwnerRef($ownerPublicId);
$company = trim(AccountBranding::companyName($settings));
return $company !== '' && strcasecmp($company, 'Company') !== 0 ? $company : null;
}
private function tryCustomerBird(
string $ownerPublicId,
string $to,
string $subject,
string $html,
?string $text,
?string $fromName = null,
): bool {
$credential = $this->credentials->forOwner($ownerPublicId);
if (! $credential->hasValidBird()) {
$this->lastError ??= 'Connect a Ladill mailbox in Account → Messaging, or connect Ladill Bird in Integrations.';
return false;
}
$apiKey = $credential->birdApiKey();
if (! $apiKey) {
$this->lastError = 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.';
return false;
}
$sent = $this->customerEmail->send(
$apiKey,
(string) $credential->bird_from_email,
$credential->bird_from_name ?: $fromName,
$to,
$subject,
$html,
$text,
);
if (! $sent) {
$this->lastError = $this->customerEmail->lastError() ?: 'The email could not be sent.';
return false;
}
return true;
}
}
@@ -1,119 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
class EventProgrammeService
{
/** @return array{title: string, days: list<array<string, mixed>>}|null */
public function snapshot(?QrCode $programme): ?array
{
if (! $programme || $programme->type !== QrCode::TYPE_ITINERARY) {
return null;
}
$content = $programme->content();
return [
'title' => (string) ($content['title'] ?? $programme->label),
'subtitle' => (string) ($content['subtitle'] ?? ''),
'location' => (string) ($content['location'] ?? ''),
'days' => array_values((array) ($content['days'] ?? [])),
];
}
/** @param list<string> $itemRefs */
public function itemsForRefs(?array $snapshot, array $itemRefs): array
{
if (! $snapshot) {
return [];
}
$refs = array_flip($itemRefs);
$matched = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
foreach ((array) ($day['items'] ?? []) as $item) {
$ref = (string) ($item['ref'] ?? '');
if ($ref !== '' && isset($refs[$ref])) {
$matched[] = $item;
}
}
}
return $matched;
}
/** @param list<array<string, mixed>> $items */
public function hostsFromItems(array $items): array
{
return collect($items)
->flatMap(function ($item) {
if (! is_array($item)) {
return [];
}
$email = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
return [$email];
}
$host = trim((string) ($item['host'] ?? ''));
if ($host !== '' && filter_var($host, FILTER_VALIDATE_EMAIL)) {
return [$host];
}
if ($host !== '') {
return [$host];
}
return [];
})
->unique()
->values()
->all();
}
/** @return list<array{email: string, name: string}> */
public function speakerOptionsForProgramme(QrCode $programme): array
{
$options = [];
foreach ($this->eventsLinkedToProgramme($programme) as $event) {
foreach ((array) ($event->content()['speakers'] ?? []) as $speaker) {
if (! is_array($speaker)) {
continue;
}
$email = trim((string) ($speaker['email'] ?? ''));
$name = trim((string) ($speaker['name'] ?? ''));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '') {
continue;
}
$options[strtolower($email)] = [
'email' => $email,
'name' => $name,
];
}
}
return array_values($options);
}
/** @return Collection<int, QrCode> */
public function eventsLinkedToProgramme(QrCode $programme): Collection
{
if ($programme->type !== QrCode::TYPE_ITINERARY) {
return collect();
}
return QrCode::query()
->where('user_id', $programme->user_id)
->where('type', QrCode::TYPE_EVENT)
->get()
->filter(fn (QrCode $event) => (int) ($event->content()['programme_qr_id'] ?? 0) === $programme->id);
}
}
+83 -185
View File
@@ -4,11 +4,10 @@ namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Meet\EventMeetAccessService;
use App\Services\Pay\PayClient;
use App\Services\Payments\MerchantGatewayService;
use App\Support\LadillLink;
use Illuminate\Support\Str;
use RuntimeException;
@@ -16,16 +15,14 @@ class EventRegistrationService
{
public function __construct(
private PayClient $pay,
private MerchantGatewayService $gateway,
private SubscriptionService $subscriptions,
private PaystackService $paystack,
private BillingClient $billing,
private SmsService $sms,
private EventEmailService $email,
private EventMeetAccessService $meetAccess,
) {}
/**
* Register an attendee. Free tiers confirm instantly; paid tiers return a
* Ladill Pay checkout URL and stay pending until payment completes.
* Paystack checkout URL and stay pending until payment completes.
*
* @param array<string, mixed> $data
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
@@ -96,14 +93,6 @@ class EventRegistrationService
}
$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,
@@ -117,7 +106,6 @@ class EventRegistrationService
'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) {
@@ -126,85 +114,50 @@ class EventRegistrationService
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);
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
$callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback');
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
$payOrder = null;
if ($usesOwnerGateway) {
try {
$payOrder = $this->gateway->initializeCheckout(
$qrCode->user,
$amountMinor,
(string) ($content['currency'] ?? 'GHS'),
(string) config('pay.mini_checkout_email', 'pay@ladill.com'),
$callbackUrl,
'EVTP-'.strtoupper(Str::random(16)),
[
'title' => $lineName,
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
'qr_code_id' => $qrCode->id,
'mode' => $mode,
'attendee_email' => $email,
],
);
} catch (\Throwable) {
$usesOwnerGateway = false;
}
}
if (! $usesOwnerGateway) {
$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' => [[
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => $feeTier,
'source_service' => 'events',
'source_ref' => (string) $qrCode->id,
'callback_url' => route('qr.public.event.callback', ['shortCode' => $qrCode->short_code]),
'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,
'attendee_email' => $email,
],
]);
}
],
'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.');
}
$registration->update([
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
'payment_reference' => $payOrder['reference'],
'metadata' => array_merge((array) $registration->metadata, [
($usesOwnerGateway ? 'merchant_gateway_init' : 'ladill_pay_init') => [
'provider' => $payOrder['provider'] ?? 'ladill_pay',
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
],
]),
]);
return [
'registration' => $registration->fresh(),
'paid' => true,
'checkout_url' => $checkoutUrl,
'access_code' => isset($payOrder['access_code']) ? (string) $payOrder['access_code'] : null,
'public_key' => isset($payOrder['public_key']) ? (string) $payOrder['public_key'] : null,
];
}
@@ -214,40 +167,25 @@ class EventRegistrationService
return $this->completeLadillPay($reference);
}
return $this->completeLegacyGateway($reference);
return $this->completeLegacy($reference);
}
private function completeLadillPay(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->with('qrCode.user')
->where('status', QrEventRegistration::STATUS_PENDING)
->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;
$platformFeeGhs = ((int) ($payOrder['platform_fee_minor'] ?? 0)) / 100;
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'amount_minor' => $amountMinor,
'pay_order_id' => $payOrder['id'] ?? $registration->pay_order_id,
'metadata' => array_merge((array) $registration->metadata, [
'ladill_pay' => $payOrder,
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
'platform_fee_ghs' => $platformFeeGhs,
]),
]);
@@ -256,41 +194,52 @@ class EventRegistrationService
return $registration;
}
/** Legacy merchant-gateway references before Ladill Pay migration. */
private function completeLegacyGateway(string $reference): QrEventRegistration
/** Legacy QREP-* references before Ladill Pay migration. */
private function completeLegacy(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']) {
$data = $this->paystack->verifyTransaction($reference);
if (($data['status'] ?? '') !== 'success') {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
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.');
}
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.09 : 0.15;
$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(),
'amount_minor' => $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor,
'metadata' => array_merge((array) $registration->metadata, [
'merchant_gateway' => [
'provider' => $result['provider'],
'reference' => $result['reference'],
'raw' => $result['raw'],
],
'platform_fee_ghs' => 0,
'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;
@@ -298,86 +247,35 @@ class EventRegistrationService
private function notifyConfirmed(QrEventRegistration $registration): void
{
$registration->loadMissing('qrCode.user');
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
if ($ownerPublicId === '') {
if (! $registration->attendee_phone) {
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,
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,
$joinUrl !== '' ? $joinUrl : null,
$registration->attendee_name,
$registration->qrCode?->user?->email,
$registration->qrCode?->user?->name,
$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 ($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 '';
$this->sms->send($registration->attendee_phone, $message);
}
private function uniqueBadgeCode(): string
@@ -1,340 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Support\Str;
class EventSpeakerInviteService
{
public const SOURCE_PROGRAMME = 'programme';
public const SOURCE_MANUAL = 'manual';
public function __construct(
private readonly EventEmailService $email,
private readonly EventProgrammeService $programmes,
private readonly EventSpeakerService $speakers,
) {}
/** @return list<array<string, mixed>> */
public function rosterWithInviteState(QrCode $event): array
{
return collect((array) ($event->content()['speakers'] ?? []))
->map(fn ($speaker) => $this->normalizeSpeakerRow(is_array($speaker) ? $speaker : []))
->filter(fn ($speaker) => ($speaker['name'] ?? '') !== '')
->values()
->all();
}
public function canSendManualInvite(QrCode $event, array $speaker): bool
{
return ($speaker['email'] ?? '') !== '';
}
/** @return array{ok: true}|array{ok: false, error: string} */
public function sendManualInvite(QrCode $event, User $owner, string $email): array
{
$email = strtolower(trim($email));
$event = $event->fresh() ?? $event;
$speaker = $this->findSpeakerByEmail($event, $email);
if ($speaker === null) {
return [
'ok' => false,
'error' => 'That speaker is not on the saved roster. Click Save speakers first, then send the invitation.',
];
}
if (! $this->canSendManualInvite($event, $speaker)) {
return [
'ok' => false,
'error' => 'Add a valid email address for this speaker before sending an invitation.',
];
}
return $this->deliverInvite($event, $owner, $speaker, self::SOURCE_MANUAL);
}
public function inviteProgrammeHosts(QrCode $programme, User $owner): int
{
if ($programme->type !== QrCode::TYPE_ITINERARY) {
return 0;
}
$sent = 0;
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
$sent += $this->inviteHostsForEvent($event->fresh(), $owner, $programme);
}
return $sent;
}
public function inviteHostsForEvent(QrCode $event, User $owner, ?QrCode $programme = null): int
{
$programme ??= $this->speakers->linkedProgramme($event);
if (! $programme) {
return 0;
}
$hostEmails = $this->hostSpeakerEmailsFromProgramme($programme);
if ($hostEmails === []) {
return 0;
}
$sent = 0;
$speakers = $this->rosterWithInviteState($event);
foreach ($speakers as $speaker) {
$email = strtolower((string) ($speaker['email'] ?? ''));
if ($email === '' || ! in_array($email, $hostEmails, true)) {
continue;
}
if (($speaker['invite_source'] ?? '') === self::SOURCE_PROGRAMME && ! empty($speaker['invited_at'])) {
continue;
}
if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)['ok']) {
$sent++;
}
}
return $sent;
}
/** @return array{event: QrCode, speaker: array<string, mixed>}|null */
public function resolvePortal(string $shortCode, string $token): ?array
{
$token = trim($token);
if ($token === '') {
return null;
}
$event = QrCode::query()
->where('short_code', $shortCode)
->where('type', QrCode::TYPE_EVENT)
->where('is_active', true)
->first();
if (! $event) {
return null;
}
foreach ($this->rosterWithInviteState($event) as $speaker) {
if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) {
return ['event' => $event, 'speaker' => $speaker];
}
}
return null;
}
/** @return array<string, mixed>|null */
public function verifyToken(QrCode $event, string $token): ?array
{
$token = trim($token);
if ($token === '') {
return null;
}
foreach ($this->rosterWithInviteState($event) as $speaker) {
if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) {
return [
'email' => $speaker['email'],
'name' => $speaker['name'],
'role' => $speaker['role'] ?? '',
];
}
}
return null;
}
public function portalUrl(QrCode $event, string $token): string
{
return $event->publicPath('speaker/'.$token);
}
/** @param array<string, mixed> $session */
public function speakerJoinUrl(QrCode $event, array $session, string $token): ?string
{
$joinUrl = trim((string) ($session['join_url'] ?? ''));
if ($joinUrl === '') {
return null;
}
$separator = str_contains($joinUrl, '?') ? '&' : '?';
return $joinUrl.$separator.'speaker='.urlencode($token);
}
/** @return list<array{session: string, day: string, time: string, location: string}> */
public function assignmentsForSpeaker(QrCode $event, array $speaker): array
{
$programme = $this->speakers->linkedProgramme($event);
$snapshot = $this->programmes->snapshot($programme);
if (! $snapshot) {
return [];
}
$email = strtolower((string) ($speaker['email'] ?? ''));
$name = trim((string) ($speaker['name'] ?? ''));
$assignments = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
$dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? ''));
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$hostEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
$hostName = trim((string) ($item['host'] ?? ''));
$matches = ($email !== '' && $hostEmail === $email)
|| ($name !== '' && strcasecmp($hostName, $name) === 0);
if (! $matches) {
continue;
}
$assignments[] = [
'session' => trim((string) ($item['title'] ?? '')),
'day' => $dayLabel,
'time' => trim((string) ($item['time'] ?? '')),
'location' => trim((string) ($item['location'] ?? '')),
];
}
}
return $assignments;
}
/** @return array{ok: true}|array{ok: false, error: string} */
private function deliverInvite(QrCode $event, User $owner, array $speaker, string $source): array
{
$email = trim((string) ($speaker['email'] ?? ''));
if ($email === '') {
return ['ok' => false, 'error' => 'Add a valid email address for this speaker before sending an invitation.'];
}
$ownerRef = trim((string) ($owner->public_id ?? ''));
if ($ownerRef === '') {
return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.'];
}
if (! app(EventMailer::class)->isConfiguredForOwner($ownerRef)) {
return ['ok' => false, 'error' => 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.'];
}
$token = (string) ($speaker['invite_token'] ?? '');
if ($token === '') {
$token = Str::random(48);
}
$eventName = (string) ($event->content()['name'] ?? $event->label);
$portalUrl = $this->portalUrl($event, $token);
$sent = $this->email->sendSpeakerInvite(
$ownerRef,
$email,
$eventName,
$portalUrl,
$speaker['name'] ?? null,
$owner->email,
$owner->name,
);
if (! $sent) {
return [
'ok' => false,
'error' => $this->email->lastError()
?: 'The invitation email could not be sent. Check your Bird email balance and verified sending domain, then try again.',
];
}
$this->persistSpeakerInviteMeta($event, $email, $token, $source);
return ['ok' => true];
}
/** @return array<string, mixed>|null */
private function findSpeakerByEmail(QrCode $event, string $email): ?array
{
foreach ($this->rosterWithInviteState($event) as $speaker) {
if (strcasecmp((string) ($speaker['email'] ?? ''), $email) === 0) {
return $speaker;
}
}
return null;
}
private function persistSpeakerInviteMeta(QrCode $event, string $email, string $token, string $source): void
{
$email = strtolower(trim($email));
$content = $event->content();
$speakers = [];
foreach ((array) ($content['speakers'] ?? []) as $row) {
if (! is_array($row)) {
continue;
}
$rowEmail = strtolower(trim((string) ($row['email'] ?? '')));
if ($rowEmail === $email) {
$row['invite_token'] = $token;
$row['invited_at'] = now()->toIso8601String();
$row['invite_source'] = $source;
}
$speakers[] = $row;
}
$payload = (array) ($event->payload ?? []);
$payload['content'] = array_merge($content, ['speakers' => $speakers]);
$event->payload = $payload;
$event->save();
}
/** @return list<string> */
private function hostSpeakerEmailsFromProgramme(QrCode $programme): array
{
$emails = [];
foreach ((array) ($programme->content()['days'] ?? []) as $day) {
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$email = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[] = $email;
}
}
}
return array_values(array_unique($emails));
}
/** @param array<string, mixed> $speaker */
private function normalizeSpeakerRow(array $speaker): array
{
$name = trim((string) ($speaker['name'] ?? ''));
$email = trim((string) ($speaker['email'] ?? ''));
return [
'name' => $name,
'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '',
'role' => trim((string) ($speaker['role'] ?? '')),
'bio' => trim((string) ($speaker['bio'] ?? '')),
'invite_token' => trim((string) ($speaker['invite_token'] ?? '')),
'invited_at' => $speaker['invited_at'] ?? null,
'invite_source' => $speaker['invite_source'] ?? null,
];
}
}
-127
View File
@@ -1,127 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
class EventSpeakerService
{
public function __construct(private readonly EventProgrammeService $programmes) {}
public function linkedProgramme(QrCode $event): ?QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
return null;
}
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::query()
->where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
/** @return list<array{name: string, email: string, role: string, bio: string, source: string}> */
public function rosterFor(QrCode $event): array
{
if ($event->type !== QrCode::TYPE_EVENT) {
return [];
}
$content = $event->content();
$roster = [];
foreach ((array) ($content['speakers'] ?? []) as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$roster[] = [
'name' => $name,
'email' => trim((string) ($speaker['email'] ?? '')),
'role' => trim((string) ($speaker['role'] ?? '')),
'bio' => trim((string) ($speaker['bio'] ?? '')),
'source' => 'roster',
];
}
foreach ($this->programmeAssignments($event) as $assignment) {
$name = trim((string) ($assignment['name'] ?? ''));
if ($name === '') {
continue;
}
$exists = collect($roster)->contains(fn ($row) => strcasecmp($row['name'], $name) === 0);
if ($exists) {
continue;
}
$roster[] = [
'name' => $name,
'email' => '',
'role' => trim((string) ($assignment['session'] ?? '')),
'bio' => '',
'source' => 'programme',
];
}
return $roster;
}
/** @return list<array{name: string, session: string, day: string, time: string}> */
public function programmeAssignments(QrCode $event): array
{
$programme = $this->linkedProgramme($event);
$snapshot = $this->programmes->snapshot($programme);
if (! $snapshot) {
return [];
}
$assignments = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
$dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? ''));
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$host = trim((string) ($item['host'] ?? ''));
if ($host === '') {
continue;
}
$assignments[] = [
'name' => $host,
'session' => trim((string) ($item['title'] ?? '')),
'day' => $dayLabel,
'time' => trim((string) ($item['time'] ?? '')),
];
}
}
return $assignments;
}
/** @return Collection<int, QrCode> */
public function eventsForAccount(int $userId, ?string $search = null): Collection
{
return QrCode::query()
->where('user_id', $userId)
->where('type', QrCode::TYPE_EVENT)
->when($search !== null && $search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get();
}
}
-305
View File
@@ -1,305 +0,0 @@
<?php
namespace App\Services\Events;
use App\Models\Events\ProSubscription;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class SubscriptionService
{
public function __construct(private readonly BillingClient $billing) {}
public function gatingActive(): bool
{
// Paid Events subscriptions are retired — product is free for all accounts.
return false;
}
public function priceMinor(): int
{
return (int) config('events.plans.pro.price_minor', config('events.pro.price_minor', 4900));
}
public function enterprisePriceMinor(): int
{
return (int) config('events.plans.enterprise.price_minor', 14900);
}
public function subscriptionFor(User $user): ?ProSubscription
{
if (! Schema::hasTable('events_pro_subscriptions')) {
return null;
}
return ProSubscription::where('user_id', $user->id)->first();
}
public function planKey(User $user): string
{
if (! $this->gatingActive()) {
return ProSubscription::PLAN_PRO;
}
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return 'free';
}
return $sub->plan === ProSubscription::PLAN_ENTERPRISE
? ProSubscription::PLAN_ENTERPRISE
: ProSubscription::PLAN_PRO;
}
public function hasPaidPlan(User $user): bool
{
if (! $this->gatingActive()) {
return true;
}
return (bool) $this->subscriptionFor($user)?->entitled();
}
public function isEnterprise(User $user): bool
{
return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE;
}
public function isPro(User $user): bool
{
return $this->hasPaidPlan($user);
}
/** Custom / owner payment gateways are retired — Ladill Pay only. */
public function canUsePaymentGateway(User $user): bool
{
return false;
}
public function liveEventCount(User $user): int
{
return QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->count();
}
public function canCreateEvent(User $user): bool
{
if ($this->isPro($user)) {
return true;
}
return $this->liveEventCount($user) < (int) config('events.free.max_live_events', 2);
}
public function ticketsThisMonth(User $user): int
{
return QrEventRegistration::query()
->where('user_id', $user->id)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('created_at', '>=', now()->startOfMonth())
->count();
}
public function canAcceptTicket(User $user): bool
{
if ($this->isPro($user)) {
return true;
}
return $this->ticketsThisMonth($user) < (int) config('events.free.max_tickets_per_month', 100);
}
/** @return array{0:bool,1:string} */
public function subscribe(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) {
return $this->subscribeEnterprise($user);
}
return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Events Pro — monthly subscription', 'Welcome to Ladill Events Pro!');
}
/** @return array{0:bool,1:string} */
public function subscribeEnterprise(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
return $this->activateWalletPlan(
$user,
ProSubscription::PLAN_ENTERPRISE,
$this->enterprisePriceMinor(),
'Ladill Events Business — monthly subscription',
'Welcome to Ladill Events Business!',
);
}
public function activatePrepaid(User $user, string $plan, int $months): void
{
$existing = $this->subscriptionFor($user);
$price = $plan === ProSubscription::PLAN_ENTERPRISE
? $this->enterprisePriceMinor()
: $this->priceMinor();
$periodEnd = Carbon::now()->addMonths($months);
$reference = 'events-prepaid-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis');
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
// Prepaid checkout already writes entitlements on the platform; this is a repair/sync.
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd, 'checkout');
}
/** @return array{0:bool,1:string} */
public function cancel(User $user): array
{
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return [false, 'You do not have an active subscription.'];
}
$sub->forceFill([
'status' => ProSubscription::STATUS_CANCELED,
'auto_renew' => false,
'canceled_at' => now(),
])->save();
$until = optional($sub->current_period_end)->format('d M Y');
return [true, "Auto-renew is off. You keep access until {$until}."];
}
public function renewIfDue(ProSubscription $sub): void
{
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
return;
}
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
return;
}
$user = $sub->user;
if (! $user) {
return;
}
$planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro';
$reference = 'events-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal");
if ($result['ok']) {
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
$sub->forceFill([
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'last_reference' => $reference,
'last_error' => null,
])->save();
$this->syncMessagingEntitlement($user, $sub->plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
return;
}
$graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3));
if ($graceEnd && $graceEnd->isPast()) {
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
$this->syncMessagingEntitlement($user, $sub->plan, 'past_due', $reference.'-pastdue', $sub->current_period_end);
} else {
$sub->forceFill(['last_error' => $result['error']])->save();
}
}
/** @return array{0:bool,1:string} */
private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array
{
$existing = $this->subscriptionFor($user);
$reference = 'events-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $price, $reference, $description);
if (! $result['ok']) {
if ($result['insufficient']) {
$bal = number_format(((int) $result['balance_minor']) / 100, 2);
return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."];
}
return [false, $result['error'] ?? 'Could not start your subscription. Please try again.'];
}
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => true,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
return [true, $successMessage.' Your subscription is active.'];
}
/**
* Map Events plan platform suite entitlement plan (enterprise = Business tier).
*/
private function syncMessagingEntitlement(
User $user,
string $plan,
string $status,
string $reference,
?Carbon $periodEnd,
string $source = 'wallet_debit',
): void {
$platformPlan = $plan === ProSubscription::PLAN_ENTERPRISE ? 'enterprise' : ($plan === 'free' ? 'free' : 'pro');
$publicId = (string) ($user->public_id ?? '');
if ($publicId === '') {
return;
}
$this->billing->syncSuiteEntitlement($publicId, $platformPlan, $status, $reference, [
'period_starts_at' => now()->toIso8601String(),
'period_ends_at' => $periodEnd?->toIso8601String(),
'source' => $source,
]);
}
}
-75
View File
@@ -2,9 +2,7 @@
namespace App\Services\Identity;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class IdentityClient
{
@@ -45,79 +43,6 @@ class IdentityClient
return (array) $response->json('data', []);
}
/** @return array<int, array<string, mixed>> */
public function banks(string $type = 'mobile_money'): array
{
$queryType = $type === 'mobile_money' ? 'mobile_money' : 'bank';
$response = $this->request()->get($this->url('/identity/banks'), [
'type' => $queryType,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed>|null */
public function payoutAccount(string $publicId): ?array
{
$response = $this->request()->get($this->url('/identity/payout-account'), [
'user' => $publicId,
]);
$response->throw();
$account = $response->json('data.payout_account');
return is_array($account) ? $account : null;
}
/** @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updatePayoutAccount(string $publicId, array $data): array
{
$response = $this->request()->put($this->url('/identity/payout-account'), array_merge(
['user' => $publicId],
$data,
));
$this->throwValidation($response, 'account_number');
$account = $response->json('data.payout_account');
return is_array($account) ? $account : [];
}
/** @return array<int, array<string, mixed>> */
public function withdrawals(string $publicId): array
{
$response = $this->request()->get($this->url('/identity/wallet/withdrawals'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function withdraw(string $publicId, float $amountGhs): array
{
$response = $this->request()->post($this->url('/identity/wallet/withdraw'), [
'user' => $publicId,
'amount' => $amountGhs,
]);
$this->throwValidation($response, 'amount');
return (array) $response->json('data', []);
}
private function throwValidation(Response $response, string $fallbackField = 'base'): void
{
if ($response->status() === 422) {
throw ValidationException::withMessages(
$response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']],
);
}
}
private function request()
{
return Http::withToken((string) config('identity.api_key'))
@@ -1,81 +0,0 @@
<?php
namespace App\Services\Integrations;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MailCalendarClient
{
public function isConfigured(): bool
{
return filled(config('mail_calendar.api_key')) && filled(config('mail_calendar.api_url'));
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>|null
*/
public function upsertEvent(array $payload): ?array
{
if (! $this->isConfigured()) {
return null;
}
try {
$response = Http::withToken((string) config('mail_calendar.api_key'))
->acceptJson()
->asJson()
->connectTimeout(10)
->timeout(15)
->post($this->baseUrl().'/calendar/events', $payload);
} catch (ConnectionException $e) {
Log::warning('Events mail calendar upsert failed', ['error' => $e->getMessage()]);
return null;
}
if (! $response->successful()) {
Log::warning('Events mail calendar upsert failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
public function deleteEvent(int $eventId, string $mailboxEmail, string $externalRef): bool
{
if (! $this->isConfigured()) {
return false;
}
try {
$response = Http::withToken((string) config('mail_calendar.api_key'))
->acceptJson()
->connectTimeout(10)
->timeout(15)
->delete($this->baseUrl().'/calendar/events/'.$eventId, [
'mailbox_email' => strtolower(trim($mailboxEmail)),
'external_ref' => $externalRef,
]);
} catch (ConnectionException $e) {
Log::warning('Events mail calendar delete failed', ['error' => $e->getMessage()]);
return false;
}
return $response->successful();
}
protected function baseUrl(): string
{
return rtrim((string) config('mail_calendar.api_url'), '/');
}
}
@@ -1,75 +0,0 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use Illuminate\Support\Facades\Log;
class EventMeetAccessService
{
public function syncRegistration(QrEventRegistration $registration): void
{
$event = $registration->qrCode;
if (! $event || $event->type !== QrCode::TYPE_EVENT) {
return;
}
$format = (string) ($event->content()['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
return;
}
if ((string) config('meet.key', '') === '' || ! $registration->attendee_email) {
return;
}
$ownerRef = (string) $event->user->public_id;
$client = MeetClient::for($ownerRef);
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$roomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
if ($roomUuid === '') {
continue;
}
try {
$client->updateRoom($roomUuid, [
'invite_emails' => [$registration->attendee_email],
]);
} catch (\Throwable $e) {
Log::warning('Events Meet invite failed', [
'registration_id' => $registration->id,
'room_uuid' => $roomUuid,
'error' => $e->getMessage(),
]);
}
}
$metadata = (array) ($registration->metadata ?? []);
$metadata['meet_invited_at'] = now()->toIso8601String();
$registration->update(['metadata' => $metadata]);
}
public function isJoinWindowOpen(array $session, int $minutesBefore = 15): bool
{
$scheduled = trim((string) ($session['scheduled_at'] ?? ''));
if ($scheduled === '') {
return true;
}
try {
$start = \Carbon\Carbon::parse($scheduled);
} catch (\Throwable) {
return true;
}
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
return now()->between($start->copy()->subMinutes($minutesBefore), $start->copy()->addMinutes($duration));
}
}
@@ -1,151 +0,0 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Support\Str;
class EventMeetRoomLinkService
{
public function __construct(private readonly EventMeetSyncService $sync) {}
/**
* @return list<array<string, mixed>>
*/
public function linkableEvents(User $owner): array
{
return QrCode::query()
->where('user_id', $owner->id)
->where('type', QrCode::TYPE_EVENT)
->orderByDesc('updated_at')
->get()
->filter(function (QrCode $event) {
$format = (string) ($event->content()['format'] ?? 'in_person');
return in_array($format, ['virtual', 'hybrid'], true);
})
->map(fn (QrCode $event) => $this->summarizeEvent($event))
->values()
->all();
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function linkRoom(User $owner, QrCode $event, array $payload): array
{
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 422);
$format = (string) ($event->content()['format'] ?? 'in_person');
abort_unless(in_array($format, ['virtual', 'hybrid'], true), 422, 'Only virtual or hybrid events can be linked.');
$roomUuid = trim((string) ($payload['meet_room_uuid'] ?? ''));
$roomType = in_array($payload['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $payload['meet_room_type']
: 'town_hall';
abort_unless($roomUuid !== '', 422, 'meet_room_uuid is required.');
$joinUrl = trim((string) ($payload['join_url'] ?? ''));
$title = trim((string) ($payload['title'] ?? $event->label ?? 'Virtual session'));
$sessionId = trim((string) ($payload['virtual_session_id'] ?? ''));
$content = $event->content();
$sessions = array_values(array_filter(
(array) ($content['virtual_sessions'] ?? []),
fn ($session) => is_array($session),
));
$matchedIndex = null;
if ($sessionId !== '') {
foreach ($sessions as $index => $session) {
if ((string) ($session['id'] ?? '') === $sessionId) {
$matchedIndex = $index;
break;
}
}
}
if ($matchedIndex === null) {
foreach ($sessions as $index => $session) {
$existingUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
$existingType = (string) ($session['meet_room_type'] ?? 'town_hall');
if ($existingUuid === '' && $existingType === $roomType) {
$matchedIndex = $index;
break;
}
}
}
if ($matchedIndex === null) {
$sessionId = $sessionId !== '' ? $sessionId : 'vs-'.Str::lower(Str::random(12));
$sessions[] = [
'id' => $sessionId,
'title' => $title,
'meet_room_type' => $roomType,
'scheduled_at' => $payload['scheduled_at'] ?? $content['starts_at'] ?? null,
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? 60)),
];
$matchedIndex = count($sessions) - 1;
} else {
$sessionId = (string) ($sessions[$matchedIndex]['id'] ?? $sessionId);
}
$sessions[$matchedIndex] = array_merge($sessions[$matchedIndex], [
'id' => $sessionId,
'title' => $title !== '' ? $title : (string) ($sessions[$matchedIndex]['title'] ?? 'Virtual session'),
'meet_room_type' => $roomType,
'meet_room_uuid' => $roomUuid,
'join_url' => $joinUrl !== '' ? $joinUrl : ($sessions[$matchedIndex]['join_url'] ?? null),
'scheduled_at' => $payload['scheduled_at'] ?? $sessions[$matchedIndex]['scheduled_at'] ?? null,
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? ($sessions[$matchedIndex]['duration_minutes'] ?? 60))),
]);
$payloadData = (array) ($event->payload ?? []);
$payloadData['content'] = array_merge($content, ['virtual_sessions' => $sessions]);
$event->payload = $payloadData;
$event->save();
$event = $this->sync->sync($event->fresh(), $owner);
$linkedSession = collect($event->content()['virtual_sessions'] ?? [])
->first(fn ($session) => is_array($session) && (string) ($session['id'] ?? '') === $sessionId);
return array_merge($this->summarizeEvent($event), [
'entity_id' => $sessionId,
'meet_room_uuid' => $roomUuid,
'join_url' => $joinUrl,
], $this->sync->programmePayloadForSession($event, is_array($linkedSession) ? $linkedSession : $sessions[$matchedIndex]));
}
/** @return array<string, mixed> */
private function summarizeEvent(QrCode $event): array
{
$content = $event->content();
$sessions = collect((array) ($content['virtual_sessions'] ?? []))
->filter(fn ($session) => is_array($session))
->map(fn (array $session) => [
'id' => (string) ($session['id'] ?? ''),
'title' => (string) ($session['title'] ?? ''),
'meet_room_type' => (string) ($session['meet_room_type'] ?? 'town_hall'),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'scheduled_at' => $session['scheduled_at'] ?? null,
'duration_minutes' => (int) ($session['duration_minutes'] ?? 60),
])
->values()
->all();
return [
'id' => $event->id,
'title' => (string) ($content['title'] ?? $event->label ?? 'Event'),
'format' => (string) ($content['format'] ?? 'in_person'),
'short_code' => $event->short_code,
'registration_url' => $event->publicUrl(),
'admin_url' => url('/events/'.$event->id.'/attendees'),
'edit_url' => url('/events/'.$event->id),
'virtual_sessions' => $sessions,
];
}
}
-213
View File
@@ -1,213 +0,0 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Events\EventProgrammeService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EventMeetSyncService
{
public function __construct(private readonly EventProgrammeService $programmes) {}
public function sync(QrCode $event, User $owner): QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
return $event;
}
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
$this->cancelRemovedSessions($event, $owner, []);
return $event;
}
if ((string) config('meet.key', '') === '') {
return $event;
}
$programmeSnapshot = $this->programmeSnapshotFor($event);
$sessions = (array) ($content['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
$synced = [];
$activeIds = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$sessionId = trim((string) ($session['id'] ?? ''));
if ($sessionId === '') {
$sessionId = 'vs-'.Str::lower(Str::random(12));
}
$activeIds[] = $sessionId;
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
$synced[] = array_merge($session, ['id' => $sessionId]);
continue;
}
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null;
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
$linkedItems = $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs);
$inviteEmails = $this->inviteEmailsFromHosts($this->programmes->hostsFromItems($linkedItems));
$settings = [
'programme_snapshot' => $programmeSnapshot,
'linked_programme_items' => $linkedItems,
];
if ($roomType === 'town_hall' && $linkedItems !== []) {
$settings['panels'] = [[
'id' => 'panel-'.$sessionId,
'name' => $title,
'speaker_refs' => [],
]];
$settings['panel_discussions'] = true;
}
$roomPayload = [
'title' => $title,
'description' => trim((string) ($content['description'] ?? '')) ?: null,
'type' => $roomType,
'scheduled_at' => $scheduledAt,
'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)),
'timezone' => config('app.timezone', 'UTC'),
'settings' => $settings,
'invite_emails' => $inviteEmails,
'source' => [
'app' => 'events',
'entity_type' => 'virtual_session',
'entity_id' => $sessionId,
'event_id' => (string) $event->id,
],
];
$existingRoomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
try {
$result = $existingRoomUuid !== ''
? $client->updateRoom($existingRoomUuid, $roomPayload)
: $client->upsertRoom($roomPayload);
} catch (\Throwable $e) {
Log::warning('Events Meet sync failed', [
'event_id' => $event->id,
'session_id' => $sessionId,
'error' => $e->getMessage(),
]);
$synced[] = array_merge($session, ['id' => $sessionId]);
continue;
}
$room = (array) ($result['room'] ?? []);
$synced[] = array_merge($session, [
'id' => $sessionId,
'meet_room_uuid' => $room['uuid'] ?? $session['meet_room_uuid'] ?? null,
'join_url' => $result['join_url'] ?? $session['join_url'] ?? null,
]);
}
$this->cancelRemovedSessions($event, $owner, $activeIds);
$payload = (array) ($event->payload ?? []);
$payload['content'] = array_merge($content, ['virtual_sessions' => array_values($synced)]);
$event->payload = $payload;
$event->save();
return $event->fresh();
}
public function syncEventsForProgramme(QrCode $programme, User $owner): void
{
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
$this->sync($event->fresh(), $owner);
}
}
/**
* @param array<string, mixed> $session
* @return array{programme_snapshot: array<string, mixed>|null, linked_programme_items: list<array<string, mixed>>}
*/
public function programmePayloadForSession(QrCode $event, array $session): array
{
$programmeSnapshot = $this->programmeSnapshotFor($event);
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
return [
'programme_snapshot' => $programmeSnapshot,
'linked_programme_items' => $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs),
];
}
/** @return array<string, mixed>|null */
private function programmeSnapshotFor(QrCode $event): ?array
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
$programme = QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
return $this->programmes->snapshot($programme);
}
/** @param list<string> $hosts */
private function inviteEmailsFromHosts(array $hosts): array
{
return collect($hosts)
->filter(fn ($host) => filter_var($host, FILTER_VALIDATE_EMAIL))
->values()
->all();
}
/** @param list<string> $keepIds */
private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void
{
if ((string) config('meet.key', '') === '') {
return;
}
$previous = (array) ($event->content()['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
foreach ($previous as $session) {
if (! is_array($session)) {
continue;
}
$sessionId = (string) ($session['id'] ?? '');
$roomUuid = (string) ($session['meet_room_uuid'] ?? '');
if ($sessionId === '' || in_array($sessionId, $keepIds, true) || $roomUuid === '') {
continue;
}
try {
$client->cancelRoom($roomUuid);
} catch (\Throwable $e) {
Log::warning('Events Meet cancel failed', [
'event_id' => $event->id,
'session_id' => $sessionId,
'error' => $e->getMessage(),
]);
}
}
}
}
@@ -1,37 +0,0 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
class EventRegistrationVerifyService
{
/** @return array<string, mixed>|null */
public function verifyBadge(QrCode $event, string $badgeCode): ?array
{
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$normalized = strtoupper(trim($badgeCode));
if ($normalized === '') {
return null;
}
$registration = QrEventRegistration::query()
->where('qr_code_id', $event->id)
->where('badge_code', $normalized)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->first();
if (! $registration) {
return null;
}
return [
'attendee_name' => $registration->attendee_name,
'attendee_email' => $registration->attendee_email,
'badge_code' => $registration->badge_code,
'tier_name' => $registration->tier_name,
];
}
}
-122
View File
@@ -1,122 +0,0 @@
<?php
namespace App\Services\Meet;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
/**
* Client for the Ladill Meet service API virtual sessions linked to Events.
*/
class MeetClient
{
public function __construct(private readonly string $ownerRef) {}
public static function for(string $ownerRef): self
{
return new self($ownerRef);
}
/**
* @return array<string, mixed>|null
*/
public function findBySource(string $app, string $entityId): ?array
{
try {
$response = $this->client()->get('rooms/by-source', [
'source_app' => $app,
'entity_id' => $entityId,
'owner_ref' => $this->ownerRef,
]);
} catch (ConnectionException) {
return null;
}
if ($response->status() === 404) {
return null;
}
if ($response->failed()) {
return null;
}
return (array) $response->json();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function upsertRoom(array $data): array
{
return $this->post('rooms', array_merge($data, [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
]));
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updateRoom(string $roomUuid, array $data): array
{
return $this->patch("rooms/{$roomUuid}", array_merge($data, [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
]));
}
public function cancelRoom(string $roomUuid, ?string $hostUserRef = null): array
{
return $this->post("rooms/{$roomUuid}/cancel", [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $hostUserRef ?? $this->ownerRef,
]);
}
private function client(): PendingRequest
{
return Http::baseUrl((string) config('meet.url'))
->withToken((string) config('meet.key'))
->acceptJson()
->asJson()
->connectTimeout(10)
->timeout(20);
}
/**
* @return array<string, mixed>
*/
private function post(string $path, array $data): array
{
return $this->request('post', $path, $data);
}
/**
* @return array<string, mixed>
*/
private function patch(string $path, array $data): array
{
return $this->request('patch', $path, $data);
}
/**
* @return array<string, mixed>
*/
private function request(string $method, string $path, array $data): array
{
try {
$response = $this->client()->{$method}($path, $data);
} catch (ConnectionException) {
throw new \RuntimeException('Could not reach the Meet service.');
}
if ($response->failed()) {
throw new \RuntimeException('Meet service error: '.($response->json('message') ?? $response->body()));
}
return (array) $response->json();
}
}
@@ -1,71 +0,0 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use Illuminate\Support\Facades\Log;
class MeetLifecycleService
{
/** @param array<string, mixed> $payload */
public function handle(string $event, array $payload): void
{
$room = (array) ($payload['room'] ?? []);
$source = (array) ($room['source'] ?? []);
if (($source['app'] ?? '') !== 'events') {
return;
}
$eventId = (int) ($source['event_id'] ?? 0);
$sessionId = (string) ($source['entity_id'] ?? '');
if ($eventId <= 0 || $sessionId === '') {
return;
}
$qrCode = QrCode::find($eventId);
if (! $qrCode || $qrCode->type !== QrCode::TYPE_EVENT) {
return;
}
$content = $qrCode->content();
$sessions = (array) ($content['virtual_sessions'] ?? []);
$updated = false;
foreach ($sessions as $index => $session) {
if (! is_array($session) || ($session['id'] ?? '') !== $sessionId) {
continue;
}
$sessions[$index] = array_merge($session, $this->patchForEvent($event, $payload, $room));
$updated = true;
break;
}
if (! $updated) {
return;
}
$payloadData = (array) ($qrCode->payload ?? []);
$payloadData['content'] = array_merge($content, ['virtual_sessions' => array_values($sessions)]);
$qrCode->update(['payload' => $payloadData]);
Log::info('Events Meet lifecycle applied', ['event' => $event, 'event_id' => $eventId, 'session_id' => $sessionId]);
}
/** @return array<string, mixed> */
private function patchForEvent(string $event, array $payload, array $room): array
{
return match ($event) {
'meeting.started' => ['meet_status' => 'live', 'meet_live_at' => now()->toIso8601String()],
'meeting.ended' => ['meet_status' => 'ended', 'meet_ended_at' => now()->toIso8601String()],
'meeting.cancelled' => ['meet_status' => 'cancelled'],
'recording.ready' => [
'recording_url' => (string) (($payload['recording']['download_url'] ?? '') ?: ''),
'recording_ready_at' => now()->toIso8601String(),
],
default => [],
};
}
}
@@ -1,92 +0,0 @@
<?php
namespace App\Services\Messaging;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CustomerEmailClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('smtp.customer_relay_url', 'https://ladill.com/api/smtp'), '/');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function me(string $apiKey): array
{
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(15)
->get($this->base().'/me');
if ($res->status() === 401) {
return ['ok' => false, 'error' => 'Invalid Bird API key.', 'status' => 401];
}
if ($res->failed()) {
return [
'ok' => false,
'error' => (string) ($res->json('error') ?: 'Could not validate Bird credentials.'),
'status' => $res->status(),
];
}
return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()];
} catch (\Throwable $e) {
Log::warning('Customer Bird meta request failed', ['error' => $e->getMessage()]);
return ['ok' => false, 'error' => 'Could not reach Ladill Bird. Please try again.'];
}
}
public function send(
string $apiKey,
string $from,
?string $fromName,
string $to,
string $subject,
string $html,
?string $text = null,
): bool {
$this->lastError = null;
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(30)
->post($this->base().'/send', array_filter([
'from' => $from,
'from_name' => $fromName,
'to' => [$to],
'subject' => $subject,
'html' => $html,
'text' => $text,
], fn ($value) => $value !== null && $value !== ''));
if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Top up Bird and try again.');
return false;
}
if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The email could not be sent.');
Log::warning('Customer email send failed', ['status' => $res->status()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'Could not reach the email service. Please try again.';
Log::warning('Customer email send error', ['error' => $e->getMessage()]);
return false;
}
}
}
@@ -1,94 +0,0 @@
<?php
namespace App\Services\Messaging;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CustomerSmsClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('sms.customer_relay_url', 'https://ladill.com/api/sms'), '/');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function me(string $apiKey): array
{
return $this->get($apiKey, '/me');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function senders(string $apiKey): array
{
return $this->get($apiKey, '/senders');
}
public function send(string $apiKey, string $to, string $text, string $senderId): bool
{
$this->lastError = null;
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(30)
->post($this->base().'/send', [
'to' => $to,
'text' => $text,
'sender_id' => $senderId,
]);
if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'Insufficient SMS credit. Top up your Ladill SMS wallet and try again.');
return false;
}
if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The SMS could not be sent.');
Log::warning('Customer SMS send failed', ['status' => $res->status()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
$this->lastError = 'Could not reach the SMS service. Please try again.';
Log::warning('Customer SMS send error', ['error' => $e->getMessage()]);
return false;
}
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
private function get(string $apiKey, string $path): array
{
try {
$res = Http::withToken($apiKey)->acceptJson()->timeout(15)
->get($this->base().$path);
if ($res->status() === 401) {
return ['ok' => false, 'error' => 'Invalid SMS API key.', 'status' => 401];
}
if ($res->failed()) {
return [
'ok' => false,
'error' => (string) ($res->json('error') ?: 'Could not validate SMS credentials.'),
'status' => $res->status(),
];
}
return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()];
} catch (\Throwable $e) {
Log::warning('Customer SMS meta request failed', ['path' => $path, 'error' => $e->getMessage()]);
return ['ok' => false, 'error' => 'Could not reach Ladill SMS. Please try again.'];
}
}
}
@@ -1,163 +0,0 @@
<?php
namespace App\Services\Messaging;
use App\Models\MessagingCredential;
class MessagingCredentialsService
{
public function __construct(
private CustomerSmsClient $sms,
private CustomerEmailClient $email,
) {}
public function forOwner(string $ownerRef): MessagingCredential
{
return MessagingCredential::query()->firstOrCreate(
['owner_ref' => $ownerRef],
);
}
/**
* @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list<string>}
*/
public function validateAndSaveSms(string $ownerRef, string $apiKey, string $senderId): array
{
$apiKey = trim($apiKey);
$senderId = trim($senderId);
if (! str_starts_with($apiKey, 'lsk_sms_live_')) {
return ['ok' => false, 'error' => 'SMS API keys must start with lsk_sms_live_.'];
}
if ($senderId === '' || strlen($senderId) > 11) {
return ['ok' => false, 'error' => 'Sender ID must be 111 characters.'];
}
$me = $this->sms->me($apiKey);
if (! ($me['ok'] ?? false)) {
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_status' => MessagingCredential::STATUS_INVALID,
'sms_last_error' => $me['error'] ?? 'Invalid SMS API key.',
]);
return ['ok' => false, 'error' => $me['error'] ?? 'Invalid SMS API key.', 'credential' => $credential];
}
$senders = $this->sms->senders($apiKey);
$approved = collect($senders['data']['data'] ?? [])->pluck('sender_id')->map(fn ($id) => (string) $id)->all();
$defaultSender = (string) ($senders['data']['default_sender'] ?? '');
$allowed = array_values(array_unique(array_filter([...$approved, $defaultSender])));
if ($allowed !== [] && ! in_array($senderId, $allowed, true)) {
$error = 'Sender ID is not approved for this SMS key. Approved: '.implode(', ', $allowed);
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_status' => MessagingCredential::STATUS_INVALID,
'sms_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, 'credential' => $credential, 'senders' => $allowed];
}
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_api_key_encrypted' => MessagingCredential::encryptKey($apiKey),
'sms_api_key_prefix' => substr($apiKey, 0, 16),
'sms_sender_id' => $senderId,
'sms_status' => MessagingCredential::STATUS_VALID,
'sms_validated_at' => now(),
'sms_last_error' => null,
]);
return ['ok' => true, 'credential' => $credential->fresh(), 'senders' => $allowed];
}
/**
* @return array{ok: bool, error?: string, credential?: MessagingCredential}
*/
public function validateAndSaveBird(string $ownerRef, string $apiKey, string $fromEmail, ?string $fromName): array
{
$apiKey = trim($apiKey);
$fromEmail = strtolower(trim($fromEmail));
$fromName = trim((string) $fromName);
if (! str_starts_with($apiKey, 'lsk_live_') && ! str_starts_with($apiKey, 'lsk_acct_')) {
return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_ or lsk_acct_.'];
}
if (! filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'error' => 'Enter a valid from email address.'];
}
$me = $this->email->me($apiKey);
if (! ($me['ok'] ?? false)) {
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_status' => MessagingCredential::STATUS_INVALID,
'bird_last_error' => $me['error'] ?? 'Invalid Bird API key.',
]);
return ['ok' => false, 'error' => $me['error'] ?? 'Invalid Bird API key.', 'credential' => $credential];
}
$domains = collect($me['data']['verified_domains'] ?? [])->map(fn ($d) => strtolower((string) $d))->all();
$fromDomain = strtolower((string) (explode('@', $fromEmail, 2)[1] ?? ''));
if ($domains !== [] && ! in_array($fromDomain, $domains, true)) {
$error = 'From email must use a verified Bird domain ('.implode(', ', $domains).').';
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_status' => MessagingCredential::STATUS_INVALID,
'bird_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, 'credential' => $credential];
}
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_api_key_encrypted' => MessagingCredential::encryptKey($apiKey),
'bird_api_key_prefix' => substr($apiKey, 0, 16),
'bird_from_email' => $fromEmail,
'bird_from_name' => $fromName !== '' ? $fromName : null,
'bird_status' => MessagingCredential::STATUS_VALID,
'bird_validated_at' => now(),
'bird_last_error' => null,
]);
return ['ok' => true, 'credential' => $credential->fresh()];
}
public function disconnectSms(string $ownerRef): MessagingCredential
{
$credential = $this->forOwner($ownerRef);
$credential->update([
'sms_api_key_encrypted' => null,
'sms_api_key_prefix' => null,
'sms_sender_id' => null,
'sms_status' => null,
'sms_validated_at' => null,
'sms_last_error' => null,
]);
return $credential->fresh();
}
public function disconnectBird(string $ownerRef): MessagingCredential
{
$credential = $this->forOwner($ownerRef);
$credential->update([
'bird_api_key_encrypted' => null,
'bird_api_key_prefix' => null,
'bird_from_email' => null,
'bird_from_name' => null,
'bird_status' => null,
'bird_validated_at' => null,
'bird_last_error' => null,
]);
return $credential->fresh();
}
}
@@ -1,310 +0,0 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use App\Services\Events\SubscriptionService;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function settingFor(User|string $owner): ?PaymentGatewaySetting
{
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
}
public function isConfigured(User|string $owner): bool
{
return $this->shouldUseOwnerGateway($owner);
}
public function shouldUseOwnerGateway(User|string $owner): bool
{
// Custom / owner payment gateways are retired. All checkouts use Ladill Pay
// with platform fees.
return false;
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
public function initializeCheckout(
User|string $owner,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata = [],
): array {
$setting = $this->requireConfigured($owner);
$currency = strtoupper($currency ?: 'GHS');
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
public function verify(User|string $owner, string $reference): array
{
// Do not re-check the current plan here: an owner-gateway payment
// initiated before a downgrade must remain verifiable.
$setting = $this->settingFor($owner);
if (! $setting?->isConfigured()) {
throw new RuntimeException('The payment gateway used for this transaction is no longer configured.');
}
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
{
if (! $this->ownerMayUseGateway($owner)) {
throw new RuntimeException('Connecting your own payment gateway requires a paid plan. Upgrade to Pro or Business.');
}
$setting = $this->settingFor($owner);
if (! $setting?->isConfigured()) {
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
}
return $setting;
}
protected function ownerMayUseGateway(User|string $owner): bool
{
return false;
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function paystackInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.paystack.co/transaction/initialize', [
'email' => $email !== '' ? $email : 'payer@example.com',
'amount' => $amountMinor,
'currency' => $currency,
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => $metadata,
]);
if (! $response->successful() || ! ($response->json('status') ?? false)) {
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
}
$url = (string) $response->json('data.authorization_url');
if ($url === '') {
throw new RuntimeException('Paystack did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => (string) ($response->json('data.reference') ?: $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? false)
&& strtolower((string) ($data['status'] ?? '')) === 'success';
return [
'paid' => $paid,
'amount_minor' => (int) ($data['amount'] ?? 0),
'reference' => (string) ($data['reference'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function flutterwaveInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.flutterwave.com/v3/payments', [
'tx_ref' => $reference,
'amount' => round($amountMinor / 100, 2),
'currency' => $currency,
'redirect_url' => $callbackUrl,
'customer' => [
'email' => $email !== '' ? $email : 'payer@example.com',
],
'customizations' => [
'title' => (string) ($metadata['title'] ?? 'Payment'),
],
'meta' => $metadata,
]);
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
}
$url = (string) $response->json('data.link');
if ($url === '') {
throw new RuntimeException('Flutterwave did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
'tx_ref' => $reference,
]);
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? '') === 'success'
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
$amountMajor = (float) ($data['amount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => (string) ($data['tx_ref'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function hubtelInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->post('https://payproxyapi.hubtel.com/items/initiate', [
'totalAmount' => round($amountMinor / 100, 2),
'description' => (string) ($metadata['title'] ?? 'Payment'),
'callbackUrl' => $callbackUrl,
'returnUrl' => $callbackUrl,
'merchantAccountNumber' => (string) $setting->public_key,
'cancellationUrl' => $callbackUrl,
'clientReference' => $reference,
]);
if (! $response->successful()) {
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
}
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
if ($url === '') {
throw new RuntimeException('Hubtel did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
{
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
'clientReference' => $reference,
]);
$data = (array) ($response->json('data') ?? $response->json() ?? []);
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
'raw' => $data,
];
}
}
+2 -24
View File
@@ -19,8 +19,6 @@ class QrCodeManagerService
private QrWalletBillingService $billing,
private QrImageGeneratorService $imageGenerator,
private QrPayloadValidator $payloadValidator,
private \App\Services\Meet\EventMeetSyncService $meetSync,
private \App\Services\Events\EventMailCalendarSyncService $mailCalendar,
) {}
public function walletFor(User $user): QrWallet
@@ -44,11 +42,7 @@ class QrCodeManagerService
$type = (string) ($data['type'] ?? '');
$validated = $this->payloadValidator->validateForCreate($type, $data);
$styleInput = $data['style'] ?? null;
if (in_array($type, [QrCode::TYPE_EVENT, QrCode::TYPE_ITINERARY], true) && empty(array_filter((array) $styleInput))) {
$styleInput = $user->getOrCreateQrSetting()->resolvedDefaultStyle();
}
$style = QrStyleDefaults::merge($styleInput);
$style = QrStyleDefaults::merge($data['style'] ?? null);
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
$documentId = null;
@@ -164,11 +158,6 @@ class QrCodeManagerService
$this->billing->debitForQrCreation($wallet, $qrCode);
$this->imageGenerator->generateAndStore($qrCode);
if ($type === QrCode::TYPE_EVENT) {
$qrCode = $this->meetSync->sync($qrCode->fresh(), $user);
$qrCode = $this->mailCalendar->sync($qrCode->fresh(), $user);
}
return $qrCode->fresh(['document']);
});
}
@@ -305,17 +294,6 @@ class QrCodeManagerService
$this->imageGenerator->generateAndStore($qrCode);
}
if ($qrCode->type === QrCode::TYPE_EVENT) {
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
$qrCode = $this->mailCalendar->sync($qrCode->fresh(), $qrCode->user);
}
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
$this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user);
app(\App\Services\Events\EventSpeakerInviteService::class)
->inviteProgrammeHosts($qrCode->fresh(), $qrCode->user);
}
return $qrCode->fresh(['document']);
}
@@ -369,7 +347,7 @@ class QrCodeManagerService
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open', 'format', 'virtual_sessions', 'speakers', 'programme_qr_id'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
QrCode::TYPE_DOCUMENT => ['allow_download'],
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
+8 -162
View File
@@ -4,7 +4,6 @@ namespace App\Services\Qr;
use App\Models\QrCode;
use App\Support\Qr\QrDateFormatter;
use Illuminate\Support\Str;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\UploadedFile;
use RuntimeException;
@@ -52,16 +51,7 @@ class QrPayloadValidator
$merged['destination_url'] = $merged['url'];
}
$result = $this->validateForCreate($qrCode->type, $merged);
if ($qrCode->type === QrCode::TYPE_EVENT && isset($result['content']['speakers'])) {
$result['content']['speakers'] = $this->mergeSpeakerInviteMeta(
(array) ($qrCode->content()['speakers'] ?? []),
$result['content']['speakers'],
);
}
return $result;
return $this->validateForCreate($qrCode->type, $merged);
}
/** @param array<string, mixed> $input */
@@ -270,13 +260,6 @@ class QrPayloadValidator
$categories = [];
}
$format = in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person';
$virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format);
$speakers = $this->normalizeSpeakers($input['speakers'] ?? null, $input);
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
'contributions' => true,
@@ -306,136 +289,11 @@ class QrPayloadValidator
'accepts_payment' => $acceptsPayment,
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
'format' => $format,
'virtual_sessions' => $virtualSessions,
'speakers' => $speakers,
],
'destination_url' => null,
];
}
/** @param mixed $sessions */
private function normalizeVirtualSessions(mixed $sessions, string $format): array
{
if ($format === 'in_person') {
return [];
}
if (! is_array($sessions)) {
return [];
}
$normalized = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
continue;
}
$id = trim((string) ($session['id'] ?? ''));
if ($id === '') {
$id = 'vs-'.Str::lower(Str::random(12));
}
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$itemRefs = [];
foreach ((array) ($session['programme_item_refs'] ?? []) as $ref) {
$ref = trim((string) $ref);
if ($ref !== '') {
$itemRefs[] = mb_substr($ref, 0, 80);
}
}
$normalized[] = [
'id' => mb_substr($id, 0, 64),
'title' => mb_substr($title, 0, 120),
'meet_room_type' => $roomType,
'scheduled_at' => QrDateFormatter::normalize((string) ($session['scheduled_at'] ?? '')),
'duration_minutes' => max(15, min(480, (int) ($session['duration_minutes'] ?? 60))),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'join_url' => ($url = trim((string) ($session['join_url'] ?? ''))) !== '' ? $url : null,
'programme_item_refs' => array_values($itemRefs),
];
}
return array_values($normalized);
}
/** @return list<array{name: string, email: string, role: string, bio: string, invite_token?: string, invited_at?: string, invite_source?: string}> */
private function normalizeSpeakers(mixed $speakers, array $input): array
{
$rows = is_array($speakers) ? $speakers : (array) ($input['speakers'] ?? []);
$normalized = [];
foreach ($rows as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$email = trim((string) ($speaker['email'] ?? ''));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Each speaker must have a valid email address.');
}
$row = [
'name' => mb_substr($name, 0, 120),
'email' => $email,
'role' => mb_substr(trim((string) ($speaker['role'] ?? '')), 0, 80),
'bio' => mb_substr(trim((string) ($speaker['bio'] ?? '')), 0, 500),
];
foreach (['invite_token', 'invited_at', 'invite_source'] as $key) {
if (! empty($speaker[$key])) {
$row[$key] = $speaker[$key];
}
}
$normalized[] = $row;
}
return array_values($normalized);
}
/**
* @param list<array<string, mixed>> $previous
* @param list<array<string, mixed>> $current
* @return list<array<string, mixed>>
*/
private function mergeSpeakerInviteMeta(array $previous, array $current): array
{
$byEmail = collect($previous)
->filter(fn ($row) => is_array($row) && filled($row['email'] ?? null))
->keyBy(fn ($row) => strtolower((string) $row['email']));
return collect($current)->map(function (array $speaker) use ($byEmail) {
$email = strtolower((string) ($speaker['email'] ?? ''));
$prev = $byEmail->get($email);
if (! $prev) {
return $speaker;
}
foreach (['invite_token', 'invited_at', 'invite_source'] as $key) {
if (! empty($prev[$key]) && empty($speaker[$key])) {
$speaker[$key] = $prev[$key];
}
}
return $speaker;
})->values()->all();
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
@@ -458,12 +316,12 @@ class QrPayloadValidator
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
$days = [];
foreach ((array) ($input['days'] ?? []) as $dayIndex => $day) {
foreach ((array) ($input['days'] ?? []) as $day) {
if (! is_array($day)) {
continue;
}
$items = [];
foreach ((array) ($day['items'] ?? []) as $itemIndex => $item) {
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
@@ -471,24 +329,12 @@ class QrPayloadValidator
if ($itemTitle === '') {
continue;
}
$hostSpeakerEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
if ($hostSpeakerEmail !== '' && ! filter_var($hostSpeakerEmail, FILTER_VALIDATE_EMAIL)) {
$hostSpeakerEmail = '';
}
$host = mb_substr(trim((string) ($item['host'] ?? '')), 0, 120);
if ($host === '' && $hostSpeakerEmail !== '') {
$host = mb_substr(strtok($hostSpeakerEmail, '@') ?: $hostSpeakerEmail, 0, 120);
}
$items[] = [
'ref' => 'day'.$dayIndex.'-item'.$itemIndex,
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => $host,
'host_speaker_email' => $hostSpeakerEmail,
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
];
}
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
-90
View File
@@ -1,90 +0,0 @@
<?php
namespace App\Support;
use App\Models\QrSetting;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class AccountBranding
{
public static function forUser(User $user): QrSetting
{
return $user->getOrCreateQrSetting();
}
public static function forOwnerRef(string $ownerPublicId): ?QrSetting
{
$user = User::query()->where('public_id', $ownerPublicId)->first();
return $user ? $user->getOrCreateQrSetting() : null;
}
public static function companyName(?QrSetting $settings, ?User $user = null): string
{
if ($user) {
return (string) ($user->name ?: 'Company');
}
if ($settings?->user) {
return (string) ($settings->user->name ?: 'Company');
}
if ($settings?->user_id) {
$owner = User::query()->find($settings->user_id);
return (string) ($owner?->name ?: 'Company');
}
return 'Company';
}
public static function hasCustomLogo(?QrSetting $settings): bool
{
return $settings !== null
&& $settings->logo_path !== null
&& Storage::disk('public')->exists($settings->logo_path);
}
public static function emailLogoUrl(?QrSetting $settings): ?string
{
if (! self::hasCustomLogo($settings)) {
return null;
}
$ext = strtolower(pathinfo((string) $settings->logo_path, PATHINFO_EXTENSION));
if ($ext === 'svg') {
return null;
}
$version = $settings->updated_at?->getTimestamp() ?? time();
return Storage::disk('public')->url($settings->logo_path).'?v='.$version;
}
public static function logoUrl(?QrSetting $settings): ?string
{
if (! self::hasCustomLogo($settings)) {
return null;
}
$version = $settings->updated_at?->getTimestamp() ?? time();
return Storage::disk('public')->url($settings->logo_path).'?v='.$version;
}
public static function storeLogo(QrSetting $settings, UploadedFile $file): string
{
self::deleteStoredLogo($settings);
return $file->store('events/accounts/'.$settings->user_id, 'public');
}
public static function deleteStoredLogo(QrSetting $settings): void
{
if ($settings->logo_path) {
Storage::disk('public')->delete($settings->logo_path);
}
}
}
-634
View File
@@ -1,634 +0,0 @@
<?php
namespace App\Support;
/**
* Canonical shared demo world for the Ladill suite.
*
* Single source of truth for business identity, people, companies, branches,
* and staff rosters used across Care, Queue, Frontdesk, CRM, Invoice,
* Accounting, POS, Meet, Events, and Woo. Suite apps copy this file via
* `php scripts/sync-demo-world.php` keep copies byte-identical.
*
* Continuity is by matching identifiers (phone, email, national_id, company
* name), not cross-database foreign keys.
*
* Reseed policy: only owner demo emails (`demo-{tier}@ladill.com`) trigger
* full product reseed on login. Staff logins never wipe mid-demo.
*/
final class DemoWorld
{
public const PASSWORD_ENV = 'DEMO_ACCOUNT_PASSWORD';
public const DEFAULT_PASSWORD = 'DemoLadill2026!';
public const TIMEZONE = 'Africa/Accra';
public const CURRENCY = 'GHS';
/** Anchor “today” for same-day appointments / tickets / visits (relative). */
public const STORY_DAY_OFFSET = 0;
/**
* @var array<string, array{
* name: string,
* legal_name: string,
* phone: string,
* email: string,
* address: string,
* city: string,
* region: string,
* timezone: string
* }>
*/
public const BUSINESS = [
'free' => [
'name' => 'Ridge Family Clinic',
'legal_name' => 'Ridge Family Clinic Ltd',
'phone' => '+233302400100',
'email' => 'hello@ridgeclinic.demo.ladill.com',
'address' => '12 Castle Road, Ridge, Accra',
'city' => 'Accra',
'region' => 'Greater Accra',
'timezone' => self::TIMEZONE,
],
'pro' => [
'name' => 'Ridge Medical Centre',
'legal_name' => 'Ridge Medical Centre Ltd',
'phone' => '+233302400200',
'email' => 'hello@ridgemedical.demo.ladill.com',
'address' => '45 Independence Ave, Ridge, Accra',
'city' => 'Accra',
'region' => 'Greater Accra',
'timezone' => self::TIMEZONE,
],
'enterprise' => [
'name' => 'Accra Healthcare Group',
'legal_name' => 'Accra Healthcare Group Ltd',
'phone' => '+233302400300',
'email' => 'hello@accrahealth.demo.ladill.com',
'address' => '1 Liberation Road, Airport Residential, Accra',
'city' => 'Accra',
'region' => 'Greater Accra',
'timezone' => self::TIMEZONE,
],
];
/**
* Shared branch catalog. Plans take the first N (free=1, pro=3, enterprise=6).
*
* @var list<array{code: string, name: string, phone: string, address: string}>
*/
public const BRANCHES = [
['code' => 'HQ', 'name' => 'Ridge Clinic', 'phone' => '+233302400101', 'address' => '12 Castle Road, Ridge, Accra'],
['code' => 'EL', 'name' => 'East Legon Care', 'phone' => '+233302400102', 'address' => '18 Boundary Rd, East Legon, Accra'],
['code' => 'TM', 'name' => 'Tema Harbour Clinic', 'phone' => '+233302400103', 'address' => 'Harbour Road, Community 1, Tema'],
['code' => 'OS', 'name' => 'Osu Medical Centre', 'phone' => '+233302400104', 'address' => 'Oxford Street, Osu, Accra'],
['code' => 'SX', 'name' => 'Spintex Wellness', 'phone' => '+233302400105', 'address' => 'Spintex Road, Accra'],
['code' => 'AP', 'name' => 'Airport Residential Care', 'phone' => '+233302400106', 'address' => 'Liberation Rd, Airport Residential, Accra'],
];
/**
* Canonical people reused as patients / visitors / ticket guests / CRM
* contacts / invoice clients / POS customers / event attendees.
*
* @var list<array{
* key: string,
* first_name: string,
* last_name: string,
* phone: string,
* email: string,
* national_id: string,
* gender: string,
* dob: string,
* company_key: string,
* city: string,
* address: string
* }>
*/
public const PEOPLE = [
['key' => 'ama-mensah', 'first_name' => 'Ama', 'last_name' => 'Mensah', 'phone' => '+233241000001', 'email' => 'ama.mensah@demo.ladill.com', 'national_id' => 'GHA-729001001', 'gender' => 'female', 'dob' => '1990-03-12', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => '14 Ring Road Central, Accra'],
['key' => 'kofi-asante', 'first_name' => 'Kofi', 'last_name' => 'Asante', 'phone' => '+233241000002', 'email' => 'kofi.asante@demo.ladill.com', 'national_id' => 'GHA-729001002', 'gender' => 'male', 'dob' => '1985-07-21', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => '3 Labone Crescent, Accra'],
['key' => 'abena-osei', 'first_name' => 'Abena', 'last_name' => 'Osei', 'phone' => '+233241000003', 'email' => 'abena.osei@demo.ladill.com', 'national_id' => 'GHA-729001003', 'gender' => 'female', 'dob' => '1992-11-05', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 4, Tema'],
['key' => 'kwame-boateng', 'first_name' => 'Kwame', 'last_name' => 'Boateng', 'phone' => '+233241000004', 'email' => 'kwame.boateng@demo.ladill.com', 'national_id' => 'GHA-729001004', 'gender' => 'male', 'dob' => '1978-01-30', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => '8 Cantonments Road, Accra'],
['key' => 'akosua-owusu', 'first_name' => 'Akosua', 'last_name' => 'Owusu', 'phone' => '+233241000005', 'email' => 'akosua.owusu@demo.ladill.com', 'national_id' => 'GHA-729001005', 'gender' => 'female', 'dob' => '1995-06-18', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => '22 Dansoman Estates, Accra'],
['key' => 'yaw-adjei', 'first_name' => 'Yaw', 'last_name' => 'Adjei', 'phone' => '+233241000006', 'email' => 'yaw.adjei@demo.ladill.com', 'national_id' => 'GHA-729001006', 'gender' => 'male', 'dob' => '1988-09-09', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Spintex Junction, Accra'],
['key' => 'efua-appiah', 'first_name' => 'Efua', 'last_name' => 'Appiah', 'phone' => '+233241000007', 'email' => 'efua.appiah@demo.ladill.com', 'national_id' => 'GHA-729001007', 'gender' => 'female', 'dob' => '1993-02-14', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Oxford Street, Osu, Accra'],
['key' => 'kojo-darko', 'first_name' => 'Kojo', 'last_name' => 'Darko', 'phone' => '+233241000008', 'email' => 'kojo.darko@demo.ladill.com', 'national_id' => 'GHA-729001008', 'gender' => 'male', 'dob' => '1982-12-01', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'University Area, Legon'],
['key' => 'adwoa-agyeman', 'first_name' => 'Adwoa', 'last_name' => 'Agyeman', 'phone' => '+233241000009', 'email' => 'adwoa.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001009', 'gender' => 'female', 'dob' => '1991-04-22', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'Airport Residential, Accra'],
['key' => 'kwesi-sarpong', 'first_name' => 'Kwesi', 'last_name' => 'Sarpong', 'phone' => '+233241000010', 'email' => 'kwesi.sarpong@demo.ladill.com', 'national_id' => 'GHA-729001010', 'gender' => 'male', 'dob' => '1987-08-16', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Kaneshie Market, Accra'],
['key' => 'afia-nyarko', 'first_name' => 'Afia', 'last_name' => 'Nyarko', 'phone' => '+233241000011', 'email' => 'afia.nyarko@demo.ladill.com', 'national_id' => 'GHA-729001011', 'gender' => 'female', 'dob' => '1994-10-03', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Adabraka, Accra'],
['key' => 'kwadwo-ankrah', 'first_name' => 'Kwadwo', 'last_name' => 'Ankrah', 'phone' => '+233241000012', 'email' => 'kwadwo.ankrah@demo.ladill.com', 'national_id' => 'GHA-729001012', 'gender' => 'male', 'dob' => '1980-05-27', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Mamprobi, Accra'],
['key' => 'esi-tetteh', 'first_name' => 'Esi', 'last_name' => 'Tetteh', 'phone' => '+233241000013', 'email' => 'esi.tetteh@demo.ladill.com', 'national_id' => 'GHA-729001013', 'gender' => 'female', 'dob' => '1996-01-19', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 8, Tema'],
['key' => 'fiifi-quaye', 'first_name' => 'Fiifi', 'last_name' => 'Quaye', 'phone' => '+233241000014', 'email' => 'fiifi.quaye@demo.ladill.com', 'national_id' => 'GHA-729001014', 'gender' => 'male', 'dob' => '1989-07-07', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Tesano, Accra'],
['key' => 'mansa-addo', 'first_name' => 'Mansa', 'last_name' => 'Addo', 'phone' => '+233241000015', 'email' => 'mansa.addo@demo.ladill.com', 'national_id' => 'GHA-729001015', 'gender' => 'female', 'dob' => '1993-09-25', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Abelemkpe, Accra'],
['key' => 'nana-frimpong', 'first_name' => 'Nana', 'last_name' => 'Frimpong', 'phone' => '+233241000016', 'email' => 'nana.frimpong@demo.ladill.com', 'national_id' => 'GHA-729001016', 'gender' => 'male', 'dob' => '1984-03-11', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Teshie, Accra'],
['key' => 'ama-boateng', 'first_name' => 'Ama', 'last_name' => 'Boateng', 'phone' => '+233241000017', 'email' => 'ama.boateng@demo.ladill.com', 'national_id' => 'GHA-729001017', 'gender' => 'female', 'dob' => '1997-12-08', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Kokomlemle, Accra'],
['key' => 'kofi-owusu', 'first_name' => 'Kofi', 'last_name' => 'Owusu', 'phone' => '+233241000018', 'email' => 'kofi.owusu@demo.ladill.com', 'national_id' => 'GHA-729001018', 'gender' => 'male', 'dob' => '1986-06-02', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'Haatso, Accra'],
['key' => 'abena-darko', 'first_name' => 'Abena', 'last_name' => 'Darko', 'phone' => '+233241000019', 'email' => 'abena.darko@demo.ladill.com', 'national_id' => 'GHA-729001019', 'gender' => 'female', 'dob' => '1990-08-29', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'Dzorwulu, Accra'],
['key' => 'kwame-mensah', 'first_name' => 'Kwame', 'last_name' => 'Mensah', 'phone' => '+233241000020', 'email' => 'kwame.mensah@demo.ladill.com', 'national_id' => 'GHA-729001020', 'gender' => 'male', 'dob' => '1979-11-13', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Ablekuma, Accra'],
['key' => 'akosua-asante', 'first_name' => 'Akosua', 'last_name' => 'Asante', 'phone' => '+233241000021', 'email' => 'akosua.asante@demo.ladill.com', 'national_id' => 'GHA-729001021', 'gender' => 'female', 'dob' => '1992-02-20', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Roman Ridge, Accra'],
['key' => 'yaw-osei', 'first_name' => 'Yaw', 'last_name' => 'Osei', 'phone' => '+233241000022', 'email' => 'yaw.osei@demo.ladill.com', 'national_id' => 'GHA-729001022', 'gender' => 'male', 'dob' => '1983-04-15', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Nungua, Accra'],
['key' => 'efua-boateng', 'first_name' => 'Efua', 'last_name' => 'Boateng', 'phone' => '+233241000023', 'email' => 'efua.boateng@demo.ladill.com', 'national_id' => 'GHA-729001023', 'gender' => 'female', 'dob' => '1995-07-01', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 2, Tema'],
['key' => 'kojo-owusu', 'first_name' => 'Kojo', 'last_name' => 'Owusu', 'phone' => '+233241000024', 'email' => 'kojo.owusu@demo.ladill.com', 'national_id' => 'GHA-729001024', 'gender' => 'male', 'dob' => '1981-10-28', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Madina, Accra'],
['key' => 'adwoa-adjei', 'first_name' => 'Adwoa', 'last_name' => 'Adjei', 'phone' => '+233241000025', 'email' => 'adwoa.adjei@demo.ladill.com', 'national_id' => 'GHA-729001025', 'gender' => 'female', 'dob' => '1994-05-06', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Ashaiman, Accra'],
['key' => 'kwesi-appiah', 'first_name' => 'Kwesi', 'last_name' => 'Appiah', 'phone' => '+233241000026', 'email' => 'kwesi.appiah@demo.ladill.com', 'national_id' => 'GHA-729001026', 'gender' => 'male', 'dob' => '1988-12-17', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Baatsona, Accra'],
['key' => 'afia-darko', 'first_name' => 'Afia', 'last_name' => 'Darko', 'phone' => '+233241000027', 'email' => 'afia.darko@demo.ladill.com', 'national_id' => 'GHA-729001027', 'gender' => 'female', 'dob' => '1991-09-23', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Labadi, Accra'],
['key' => 'kwadwo-agyeman', 'first_name' => 'Kwadwo', 'last_name' => 'Agyeman', 'phone' => '+233241000028', 'email' => 'kwadwo.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001028', 'gender' => 'male', 'dob' => '1985-01-04', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'Kwabenya, Accra'],
['key' => 'esi-sarpong', 'first_name' => 'Esi', 'last_name' => 'Sarpong', 'phone' => '+233241000029', 'email' => 'esi.sarpong@demo.ladill.com', 'national_id' => 'GHA-729001029', 'gender' => 'female', 'dob' => '1996-03-30', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'East Legon Hills, Accra'],
['key' => 'fiifi-nyarko', 'first_name' => 'Fiifi', 'last_name' => 'Nyarko', 'phone' => '+233241000030', 'email' => 'fiifi.nyarko@demo.ladill.com', 'national_id' => 'GHA-729001030', 'gender' => 'male', 'dob' => '1987-06-14', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Odorkor, Accra'],
['key' => 'mansa-ankrah', 'first_name' => 'Mansa', 'last_name' => 'Ankrah', 'phone' => '+233241000031', 'email' => 'mansa.ankrah@demo.ladill.com', 'national_id' => 'GHA-729001031', 'gender' => 'female', 'dob' => '1993-11-11', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Korle Bu, Accra'],
['key' => 'nana-tetteh', 'first_name' => 'Nana', 'last_name' => 'Tetteh', 'phone' => '+233241000032', 'email' => 'nana.tetteh@demo.ladill.com', 'national_id' => 'GHA-729001032', 'gender' => 'male', 'dob' => '1982-08-08', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Weija, Accra'],
['key' => 'ama-quaye', 'first_name' => 'Ama', 'last_name' => 'Quaye', 'phone' => '+233241000033', 'email' => 'ama.quaye@demo.ladill.com', 'national_id' => 'GHA-729001033', 'gender' => 'female', 'dob' => '1990-04-04', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 11, Tema'],
['key' => 'kofi-addo', 'first_name' => 'Kofi', 'last_name' => 'Addo', 'phone' => '+233241000034', 'email' => 'kofi.addo@demo.ladill.com', 'national_id' => 'GHA-729001034', 'gender' => 'male', 'dob' => '1989-02-26', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Kasoa Road, Accra'],
['key' => 'abena-frimpong', 'first_name' => 'Abena', 'last_name' => 'Frimpong', 'phone' => '+233241000035', 'email' => 'abena.frimpong@demo.ladill.com', 'national_id' => 'GHA-729001035', 'gender' => 'female', 'dob' => '1994-07-19', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Achimota, Accra'],
['key' => 'kwame-agyeman', 'first_name' => 'Kwame', 'last_name' => 'Agyeman', 'phone' => '+233241000036', 'email' => 'kwame.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001036', 'gender' => 'male', 'dob' => '1980-09-01', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Sakumono, Accra'],
];
/**
* @var list<array{key: string, name: string, email: string, phone: string, address: string}>
*/
public const COMPANIES = [
['key' => 'accra-retail', 'name' => 'Accra Retail Ltd', 'email' => 'accounts@accra-retail.demo.ladill.com', 'phone' => '+233302500001', 'address' => 'Oxford Street, Accra'],
['key' => 'kumasi-foods', 'name' => 'Kumasi Foods', 'email' => 'billing@kumasi-foods.demo.ladill.com', 'phone' => '+233302500002', 'address' => 'Kejetia Road, Kumasi'],
['key' => 'tema-logistics', 'name' => 'Tema Logistics', 'email' => 'finance@tema-logistics.demo.ladill.com', 'phone' => '+233302500003', 'address' => 'Harbour Road, Tema'],
['key' => 'cape-coast-tours', 'name' => 'Cape Coast Tours', 'email' => 'pay@cape-coast-tours.demo.ladill.com', 'phone' => '+233302500004', 'address' => 'Castle Road, Cape Coast'],
['key' => 'tamale-agro', 'name' => 'Tamale Agro', 'email' => 'accounts@tamale-agro.demo.ladill.com', 'phone' => '+233302500005', 'address' => 'Salaga Road, Tamale'],
['key' => 'spintex-clinics', 'name' => 'Spintex Clinics', 'email' => 'billing@spintex-clinics.demo.ladill.com', 'phone' => '+233302500006', 'address' => 'Spintex Road, Accra'],
['key' => 'osu-cafe', 'name' => 'Osu Café Co', 'email' => 'hello@osu-cafe.demo.ladill.com', 'phone' => '+233302500007', 'address' => 'Oxford Street, Osu'],
['key' => 'legon-tech', 'name' => 'Legon Tech', 'email' => 'ap@legon-tech.demo.ladill.com', 'phone' => '+233302500008', 'address' => 'University Area, Legon'],
['key' => 'airport-hotels', 'name' => 'Airport City Hotels', 'email' => 'finance@airport-hotels.demo.ladill.com', 'phone' => '+233302500009', 'address' => 'Airport City, Accra'],
['key' => 'kaneshie-traders', 'name' => 'Kaneshie Market Traders', 'email' => 'pay@kaneshie-traders.demo.ladill.com', 'phone' => '+233302500010', 'address' => 'Kaneshie Market, Accra'],
];
/**
* Plausible shared story amounts (GHS major units) for Accounting / Invoice / POS labels.
*
* @var array{care_consult: int, care_lab: int, invoice_typical: int, pos_typical: int, crm_deal: int}
*/
public const STORY_AMOUNTS_GHS = [
'care_consult' => 250,
'care_lab' => 180,
'invoice_typical' => 1500,
'pos_typical' => 85,
'crm_deal' => 12000,
];
/**
* Staff roster keyed by tier. Free: owner + receptionist. Pro/Enterprise: full roster.
*
* @var array<string, list<array{
* key: string,
* email: string,
* name: string,
* apps: list<string>,
* roles: array<string, string>,
* account_role?: string
* }>>
*/
/**
* Care specialty module doctors (Pro / Enterprise). Emails: demo-{tier}-{key}@ladill.com
*
* @var array<string, string> module key => display name
*/
public const SPECIALTY_DOCTORS = [
'emergency' => 'Dr. Kojo Emergency',
'blood_bank' => 'Dr. Ama Blood Bank',
'dentistry' => 'Dr. Ama Dental',
'ophthalmology' => 'Dr. Kofi Eye',
'physiotherapy' => 'Dr. Efua Physio',
'maternity' => 'Dr. Abena Maternity',
'radiology' => 'Dr. Yaw Radiology',
'cardiology' => 'Dr. Kwesi Cardiology',
'psychiatry' => 'Dr. Adwoa Psychiatry',
'pediatrics' => 'Dr. Fiifi Pediatrics',
'orthopedics' => 'Dr. Kojo Orthopedics',
'ent' => 'Dr. Esi ENT',
'oncology' => 'Dr. Mansa Oncology',
'renal' => 'Dr. Nana Renal',
'surgery' => 'Dr. Kwadwo Surgery',
'vaccination' => 'Dr. Afia Vaccination',
'pathology' => 'Dr. Yaw Pathology',
'infusion' => 'Dr. Akosua Infusion',
'dermatology' => 'Dr. Ama Dermatology',
'podiatry' => 'Dr. Kofi Podiatry',
'fertility' => 'Dr. Abena Fertility',
'child_welfare' => 'Dr. Efua Child Welfare',
];
public const STAFF = [
'free' => [
[
'key' => 'receptionist',
'email' => 'demo-free-receptionist@ladill.com',
'name' => 'Abena Reception (Free)',
'apps' => ['care', 'frontdesk'],
'roles' => [
'care' => 'receptionist',
'frontdesk' => 'receptionist',
],
],
[
'key' => 'queue',
'email' => 'demo-free-queue@ladill.com',
'name' => 'Ama Queue (Free)',
'apps' => ['queue'],
'roles' => ['queue' => 'receptionist'],
],
],
'pro' => [
[
'key' => 'doctor',
'email' => 'demo-pro-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
],
[
'key' => 'nurse',
'email' => 'demo-pro-nurse@ladill.com',
'name' => 'Akosua Nurse (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'nurse'],
],
[
'key' => 'receptionist',
'email' => 'demo-pro-receptionist@ladill.com',
'name' => 'Abena Reception (Pro)',
'apps' => ['care', 'frontdesk'],
'roles' => [
'care' => 'receptionist',
'frontdesk' => 'receptionist',
],
],
[
'key' => 'queue',
'email' => 'demo-pro-queue@ladill.com',
'name' => 'Ama Queue (Pro)',
'apps' => ['queue'],
'roles' => ['queue' => 'receptionist'],
],
[
'key' => 'pharmacist',
'email' => 'demo-pro-pharmacist@ladill.com',
'name' => 'Yaw Pharmacist (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'pharmacist'],
],
[
'key' => 'lab',
'email' => 'demo-pro-lab@ladill.com',
'name' => 'Efua Lab Tech (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'lab_technician'],
],
[
'key' => 'cashier',
'email' => 'demo-pro-cashier@ladill.com',
'name' => 'Kojo Cashier (Pro)',
'apps' => ['care', 'pos'],
'roles' => [
'care' => 'cashier',
'pos' => 'cashier',
],
],
[
'key' => 'accountant',
'email' => 'demo-pro-accountant@ladill.com',
'name' => 'Adwoa Accountant (Pro)',
'apps' => ['accounting', 'invoice'],
'roles' => [
'accounting' => 'member',
'invoice' => 'member',
'care' => 'accountant',
],
],
[
'key' => 'sales',
'email' => 'demo-pro-sales@ladill.com',
'name' => 'Kofi Sales (Pro)',
'apps' => ['crm', 'invoice', 'meet'],
'roles' => [
'crm' => 'member',
'invoice' => 'member',
'meet' => 'member',
],
],
],
'enterprise' => [
[
'key' => 'doctor',
'email' => 'demo-enterprise-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah',
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
],
[
'key' => 'nurse',
'email' => 'demo-enterprise-nurse@ladill.com',
'name' => 'Akosua Nurse',
'apps' => ['care'],
'roles' => ['care' => 'nurse'],
],
[
'key' => 'receptionist',
'email' => 'demo-enterprise-receptionist@ladill.com',
'name' => 'Abena Reception',
'apps' => ['care', 'frontdesk'],
'roles' => [
'care' => 'receptionist',
'frontdesk' => 'receptionist',
],
],
[
'key' => 'queue',
'email' => 'demo-enterprise-queue@ladill.com',
'name' => 'Ama Queue',
'apps' => ['queue'],
'roles' => ['queue' => 'receptionist'],
],
[
'key' => 'pharmacist',
'email' => 'demo-enterprise-pharmacist@ladill.com',
'name' => 'Yaw Pharmacist',
'apps' => ['care'],
'roles' => ['care' => 'pharmacist'],
],
[
'key' => 'lab',
'email' => 'demo-enterprise-lab@ladill.com',
'name' => 'Efua Lab Tech',
'apps' => ['care'],
'roles' => ['care' => 'lab_technician'],
],
[
'key' => 'cashier',
'email' => 'demo-enterprise-cashier@ladill.com',
'name' => 'Kojo Cashier',
'apps' => ['care', 'pos'],
'roles' => [
'care' => 'cashier',
'pos' => 'cashier',
],
],
[
'key' => 'accountant',
'email' => 'demo-enterprise-accountant@ladill.com',
'name' => 'Adwoa Accountant',
'apps' => ['accounting', 'invoice', 'care'],
'roles' => [
'accounting' => 'member',
'invoice' => 'member',
'care' => 'accountant',
],
],
[
'key' => 'sales',
'email' => 'demo-enterprise-sales@ladill.com',
'name' => 'Kofi Sales',
'apps' => ['crm', 'invoice', 'meet', 'events'],
'roles' => [
'crm' => 'member',
'invoice' => 'member',
'meet' => 'member',
'events' => 'member',
],
],
],
];
/** @var array<string, string> owner email → tier */
public const OWNER_EMAILS = [
'demo-free@ladill.com' => 'free',
'demo-pro@ladill.com' => 'pro',
'demo-enterprise@ladill.com' => 'enterprise',
];
public static function normalizeTier(string $tier): string
{
$tier = strtolower(trim($tier));
return in_array($tier, ['free', 'pro', 'enterprise'], true) ? $tier : 'free';
}
public static function ownerEmailForTier(string $tier): string
{
$tier = self::normalizeTier($tier);
return match ($tier) {
'pro' => 'demo-pro@ladill.com',
'enterprise' => 'demo-enterprise@ladill.com',
default => 'demo-free@ladill.com',
};
}
public static function tierForOwnerEmail(?string $email): ?string
{
$email = strtolower(trim((string) $email));
return self::OWNER_EMAILS[$email] ?? null;
}
/**
* @return array{name: string, legal_name: string, phone: string, email: string, address: string, city: string, region: string, timezone: string}
*/
public static function business(string $tier): array
{
return self::BUSINESS[self::normalizeTier($tier)];
}
/**
* @return list<array{code: string, name: string, phone: string, address: string}>
*/
public static function branches(string $tier, ?int $limit = null): array
{
$tier = self::normalizeTier($tier);
$limit ??= match ($tier) {
'enterprise' => 6,
'pro' => 3,
default => 1,
};
return array_slice(self::BRANCHES, 0, max(1, $limit));
}
/**
* @return list<array{key: string, first_name: string, last_name: string, phone: string, email: string, national_id: string, gender: string, dob: string, company_key: string, city: string, address: string}>
*/
public static function people(?int $limit = null): array
{
if ($limit === null) {
return self::PEOPLE;
}
return array_slice(self::PEOPLE, 0, max(0, $limit));
}
/**
* @return array{key: string, first_name: string, last_name: string, phone: string, email: string, national_id: string, gender: string, dob: string, company_key: string, city: string, address: string}|null
*/
public static function personByKey(string $key): ?array
{
foreach (self::PEOPLE as $person) {
if ($person['key'] === $key) {
return $person;
}
}
return null;
}
public static function fullName(array $person): string
{
return trim($person['first_name'].' '.$person['last_name']);
}
/**
* @return list<array{key: string, name: string, email: string, phone: string, address: string}>
*/
public static function companies(?int $limit = null): array
{
if ($limit === null) {
return self::COMPANIES;
}
return array_slice(self::COMPANIES, 0, max(0, $limit));
}
/**
* @return array{key: string, name: string, email: string, phone: string, address: string}|null
*/
public static function companyByKey(string $key): ?array
{
foreach (self::COMPANIES as $company) {
if ($company['key'] === $key) {
return $company;
}
}
return null;
}
public static function companyNameForPerson(array $person): string
{
return self::companyByKey($person['company_key'])['name'] ?? 'Demo Co';
}
/**
* @return list<array{key: string, email: string, name: string, apps: list<string>, roles: array<string, string>, account_role?: string}>
*/
public static function staff(string $tier): array
{
$tier = self::normalizeTier($tier);
$roster = self::STAFF[$tier] ?? [];
if (! in_array($tier, ['pro', 'enterprise'], true)) {
return $roster;
}
foreach (self::SPECIALTY_DOCTORS as $key => $name) {
$email = sprintf('demo-%s-%s@ladill.com', $tier, $key);
$already = false;
foreach ($roster as $row) {
if (strtolower((string) ($row['email'] ?? '')) === $email) {
$already = true;
break;
}
}
if ($already) {
continue;
}
$suffix = $tier === 'pro' ? ' (Pro)' : '';
$roster[] = [
'key' => $key,
'email' => $email,
'name' => $name.$suffix,
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
];
}
return $roster;
}
/**
* @return array{key: string, email: string, name: string, apps: list<string>, roles: array<string, string>, account_role?: string}|null
*/
public static function staffByEmail(?string $email): ?array
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
foreach (['free', 'pro', 'enterprise'] as $tier) {
foreach (self::staff($tier) as $member) {
if (strtolower($member['email']) === $email) {
return $member;
}
}
}
return null;
}
public static function isOwnerDemoEmail(?string $email): bool
{
return self::tierForOwnerEmail($email) !== null;
}
public static function isStaffDemoEmail(?string $email): bool
{
return self::staffByEmail($email) !== null;
}
/** Owner email for a staff member, or null if not staff. */
public static function ownerEmailForStaff(?string $email): ?string
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
// Use staff() so dynamically appended specialty doctors resolve too.
foreach (['free', 'pro', 'enterprise'] as $tier) {
foreach (self::staff($tier) as $member) {
if (strtolower((string) $member['email']) === $email) {
return self::ownerEmailForTier($tier);
}
}
}
return null;
}
/**
* Build Identity AccountTeamMember metadata from a staff roles map.
*
* @param array<string, string> $roles
* @return array<string, array{role: string}>
*/
public static function metadataFromRoles(array $roles): array
{
$metadata = [];
foreach ($roles as $app => $role) {
$metadata[$app] = ['role' => $role];
}
return $metadata;
}
public static function storyDay(): \Carbon\CarbonInterface
{
return now(self::TIMEZONE)->startOfDay()->addDays(self::STORY_DAY_OFFSET);
}
/** Local phone form used by some apps (024####### without +233). */
public static function phoneLocal(string $e164): string
{
if (str_starts_with($e164, '+233')) {
return '0'.substr($e164, 4);
}
return $e164;
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* Canonical short-link URLs for the Ladill platform (ladl.link).
*/
final class LadillLink
{
public const INTERNAL_HEADER = 'X-Ladill-Internal';
public static function baseUrl(): string
{
$domain = (string) config('link.public_domain', 'ladl.link');
return 'https://'.$domain;
}
public static function url(string $slug): string
{
return rtrim(self::baseUrl(), '/').'/'.ltrim($slug, '/');
}
public static function path(string $slug, string $suffix): string
{
return self::url($slug).'/'.ltrim($suffix, '/');
}
public static function isInternalRequest(Request $request): bool
{
return $request->header(self::INTERNAL_HEADER) === '1';
}
public static function legacyRedirect(Request $request): RedirectResponse
{
$shortCode = $request->route('shortCode');
if (! is_string($shortCode) || $shortCode === '') {
abort(404);
}
$path = ltrim($request->path(), '/');
$prefix = 'q/'.$shortCode;
$suffix = '';
if ($path !== $prefix && str_starts_with($path, $prefix.'/')) {
$suffix = substr($path, strlen($prefix) + 1);
}
$target = $suffix === '' ? self::url($shortCode) : self::path($shortCode, $suffix);
if ($qs = $request->getQueryString()) {
$target .= '?'.$qs;
}
$status = in_array($request->method(), ['GET', 'HEAD'], true) ? 301 : 307;
return redirect()->away($target, $status);
}
}
-163
View File
@@ -1,163 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
class UserProfileMenu
{
/**
* Standard signed-in profile links for Ladill product apps.
* Not used by Ladill Mail (mail.ladill.com) that app keeps a mailbox-specific menu.
*
* @return list<array<string, mixed>>
*/
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
if (! $user) {
return [];
}
$items = [];
foreach ([
['label' => 'Home', 'path' => '', 'host' => 'home'],
['label' => 'Profile', 'path' => 'profile'],
['label' => 'Account Settings', 'path' => 'account-settings'],
['label' => 'Dashboard', 'path' => 'dashboard'],
['label' => 'Billing', 'path' => 'billing'],
] as $link) {
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
if (self::isCurrentMenuLink($link, $href)) {
continue;
}
$items[] = [
'type' => 'link',
'label' => $link['label'],
'href' => $href,
];
}
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
$items[] = ['type' => 'wallet'];
}
if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin');
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
$items[] = [
'type' => 'link',
'label' => 'Admin',
'href' => $adminHref,
];
}
}
if (Route::has('logout')) {
$items[] = [
'type' => 'logout',
'label' => 'Logout',
'action' => route('logout'),
];
}
return $items;
}
private static function platformUrl(string $host, string $path = ''): string
{
if ($host === 'home') {
if (function_exists('ladill_home_url')) {
return ladill_home_url($path);
}
$domain = config('app.home_domain');
if (! $domain) {
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
}
return self::absoluteUrl((string) $domain, $path);
}
if (function_exists('ladill_account_url')) {
return ladill_account_url($path);
}
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/**
* Hide a menu link when the signed-in user is already on that destination.
*
* @param array{label?: string, path?: string, host?: string} $link
*/
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
$request = request();
if (! $request) {
return false;
}
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
$currentHost = strtolower($request->getHost());
if ($linkHost === '' || $linkHost !== $currentHost) {
return false;
}
$currentPath = trim($request->getPathInfo(), '/');
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
if (($link['host'] ?? 'account') === 'home') {
return $currentPath === '';
}
if (($link['path'] ?? '') === 'dashboard') {
return in_array($currentPath, ['dashboard', 'account'], true)
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
}
return self::pathMatchesSection($currentPath, $targetPath);
}
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
{
if ($targetPath === '') {
return $currentPath === '';
}
return $currentPath === $targetPath
|| str_starts_with($currentPath, $targetPath.'/');
}
private static function absoluteUrl(string $domain, string $path = ''): string
{
$base = 'https://'.trim($domain, '/');
if ($path === '') {
return $base;
}
return $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
{
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
return (bool) ($user->is_admin ?? false);
}
}
-11
View File
@@ -13,23 +13,12 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Public event registration is posted from ladl.link (cross-site); session CSRF cannot bind there.
$middleware->validateCsrfTokens(except: [
'q/*/register',
]);
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
'redirect' => $request->fullUrl(),
]));
$middleware->web(append: [
\App\Http\Middleware\InjectBootSplash::class,
\App\Http\Middleware\SetActingAccount::class,
]);
$middleware->alias([
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
'redirect.legacy.qr' => \App\Http\Middleware\RedirectLegacyQrToLadillLink::class,
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
-2
View File
@@ -4,6 +4,4 @@ return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_EVENTS'),
'service' => 'events',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
];
-27
View File
@@ -1,27 +0,0 @@
<?php
$appHost = parse_url((string) env('APP_URL', 'https://events.ladill.com'), PHP_URL_HOST) ?: 'events.ladill.com';
return [
// Custom domains are opt-in; the feature only surfaces/provisions when a
// Domains SSL API key is configured.
'enabled' => filter_var(env('CUSTOM_DOMAINS_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
// This app's own host(s) — requests on any other host are treated as a
// customer custom domain and resolved to the mapped event page.
'app_host' => $appHost,
// Where customers point their A records (apex + www).
'server_ip' => env('LADILL_APP_SERVER_IP', '161.97.138.149'),
// Central SSL provisioning (Ladill Domains).
'ssl_api_url' => rtrim(env('DOMAINS_SSL_API_URL', 'https://domains.ladill.com/api'), '/'),
'ssl_api_key' => env('DOMAINS_API_KEY_EVENTS', ''),
// certbot issues + installs an nginx server block that includes this snippet
// (root + fastcgi for the Events app) so the custom domain serves this app.
'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-events-app.conf'),
// Shared secret to verify the signed completion callback from Domains.
'callback_secret' => env('SSL_CALLBACK_SECRET', ''),
];
-18
View File
@@ -1,18 +0,0 @@
<?php
return [
'enabled' => (bool) env('DEMO_ACCOUNTS_ENABLED', false),
'reset_on_login' => (bool) env('DEMO_RESET_ON_LOGIN', true),
'accounts' => [
'demo-free@ladill.com' => 'free',
'demo-pro@ladill.com' => 'pro',
'demo-enterprise@ladill.com' => 'enterprise',
],
// Org members share the enterprise owner tenant.
'member_emails' => [
'demo-enterprise-member-1@ladill.com' => 'demo-enterprise@ladill.com',
'demo-enterprise-member-2@ladill.com' => 'demo-enterprise@ladill.com',
],
];
-34
View File
@@ -1,34 +0,0 @@
<?php
return [
'service_api_keys' => [
'meet' => env('EVENTS_API_KEY_MEET', env('MEET_API_KEY_EVENTS')),
],
'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'),
/*
| Paid Events subscriptions (Pro / Business) are retired. Keep pro.* keys for
| legacy renew command / DB rows; gating is always off via SubscriptionService.
*/
'pro' => [
'enabled' => false,
'price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 49000),
'currency' => env('EVENTS_PRO_CURRENCY', 'GHS'),
'period_days' => (int) env('EVENTS_PRO_PERIOD_DAYS', 30),
'grace_days' => (int) env('EVENTS_PRO_GRACE_DAYS', 3),
],
'plans' => [],
'prepaid_months' => [],
'free' => [
'max_live_events' => (int) env('EVENTS_FREE_MAX_LIVE_EVENTS', 2),
'max_tickets_per_month' => (int) env('EVENTS_FREE_MAX_TICKETS_PER_MONTH', 100),
],
'upgrade_banner' => null,
];
-6
View File
@@ -1,6 +0,0 @@
<?php
return [
'product_slug' => 'events',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/events'),
];
+20 -25
View File
@@ -2,44 +2,39 @@
/*
|--------------------------------------------------------------------------
| Ladill App Launcher GENERATED, IDENTICAL across every app/service repo
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo
|--------------------------------------------------------------------------
|
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
| monolith (config/ladill_apps.php). To change the launcher, edit that
| registry and run: php artisan ladill:launcher:sync --propagate
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
| launcher in a new app, copy these three things verbatim into that repo:
| - config/ladill_launcher.php (this file)
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
|
| URLs are absolute (built from the platform root) so this file is
| byte-identical in every repo; the blade omits whichever row matches the
| app's own host. Icons are served from /images/launcher-icons/<icon>.
| When a service becomes FULLY extracted: add one row below AND drop its icon in
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed.
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
*/
$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'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['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' => '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'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.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' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
],
];
-5
View File
@@ -1,5 +0,0 @@
<?php
return [
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
];
-6
View File
@@ -1,6 +0,0 @@
<?php
return [
'api_url' => env('LADILL_MAIL_API_URL', 'https://mail.ladill.com/api/service/v1'),
'api_key' => env('MAIL_API_KEY_EVENTS'),
];
-9
View File
@@ -1,9 +0,0 @@
<?php
return [
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
'key' => env('MEET_API_KEY_EVENTS'),
'webhook_secret' => env('MEET_WEBHOOK_SECRET_EVENTS'),
];
+43
View File
@@ -0,0 +1,43 @@
<?php
return [
'slug' => 'events',
'name' => 'Events',
'logo' => 'images/logo/ladillevents-logo.svg',
'icon' => 'events.svg',
'tagline' => 'Tickets and event registration',
'headline' => 'Sell tickets and manage check-in',
'accent' => 'registration, ticketing, and attendance',
'description' => 'Events handles ticket sales, RSVPs, and check-in for conferences, concerts, church programs, and community gatherings.',
'benefits' =>
[
0 =>
[
'title' => 'Paid or free events',
'text' => 'Sell tickets with mobile money and cards, or run RSVP-only events.',
,]
1 =>
[
'title' => 'Check-in tools',
'text' => 'Scan attendees at the door with QR codes.',
,]
2 =>
[
'title' => 'Attendee management',
'text' => 'Export guest lists and track attendance in real time.',
,]
,]
'use_cases' =>
[
0 => 'Conferences',
1 => 'Church programs',
2 => 'Concerts & meetups',
,]
'gradient_from' => '#7c3aed',
'gradient_to' => '#1c75bc',
'dashboard_route' => 'events.dashboard',
'platform_url' => 'https://localhost',
'marketing_url' => 'https://localhost/products/events',
'register_url' => 'https://auth.localhost/register',
'landing_variant' => 'default',
];
+1 -1
View File
@@ -32,7 +32,7 @@ return [
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
-8
View File
@@ -1,8 +0,0 @@
<?php
return [
'platform_api_url' => env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'),
'platform_api_key' => env('SMS_API_KEY_EVENTS'),
'customer_relay_url' => env('SMS_CUSTOMER_RELAY_URL', 'https://ladill.com/api/sms'),
'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'),
];
-14
View File
@@ -1,14 +0,0 @@
<?php
return [
'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'),
'platform_api_key' => env('SMTP_API_KEY_EVENTS'),
'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'),
'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'),
'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'),
// Deep link when suite path returns messaging_mailbox_required
'messaging_settings_url' => env(
'MESSAGING_SETTINGS_URL',
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
),
];
@@ -1,33 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('custom_domains', function (Blueprint $table) {
$table->id();
$table->foreignId('qr_code_id')->constrained('qr_codes')->cascadeOnDelete(); // the storefront
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('host')->unique();
$table->boolean('include_www')->default(true);
$table->string('status', 16)->default('pending'); // pending | active | failed
$table->string('ssl_status', 16)->default('pending'); // pending | active | failed
$table->timestamp('dns_verified_at')->nullable();
$table->timestamp('ssl_issued_at')->nullable();
$table->timestamp('ssl_expires_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index('qr_code_id');
});
}
public function down(): void
{
Schema::dropIfExists('custom_domains');
}
};
@@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events_messaging_credentials', function (Blueprint $table) {
$table->id();
$table->string('owner_ref')->unique();
$table->text('sms_api_key_encrypted')->nullable();
$table->string('sms_api_key_prefix', 32)->nullable();
$table->string('sms_sender_id', 11)->nullable();
$table->string('sms_status', 32)->nullable();
$table->timestamp('sms_validated_at')->nullable();
$table->text('sms_last_error')->nullable();
$table->text('bird_api_key_encrypted')->nullable();
$table->string('bird_api_key_prefix', 32)->nullable();
$table->string('bird_from_email')->nullable();
$table->string('bird_from_name')->nullable();
$table->string('bird_status', 32)->nullable();
$table->timestamp('bird_validated_at')->nullable();
$table->text('bird_last_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('events_messaging_credentials');
}
};
@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('qr_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('event_defaults');
});
}
public function down(): void
{
Schema::table('qr_settings', function (Blueprint $table) {
$table->dropColumn('logo_path');
});
}
};
@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_gateway_settings', function (Blueprint $table) {
$table->id();
$table->string('owner_ref', 64)->unique();
$table->string('provider', 32); // paystack|flutterwave|hubtel
$table->text('public_key')->nullable();
$table->text('secret_key')->nullable();
$table->text('webhook_secret')->nullable();
$table->boolean('is_active')->default(true);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_gateway_settings');
}
};
@@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events_pro_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('status', 16)->default('active');
$table->string('plan', 16)->default('pro');
$table->unsignedInteger('price_minor');
$table->char('currency', 3)->default('GHS');
$table->boolean('auto_renew')->default(true);
$table->timestamp('started_at')->nullable();
$table->timestamp('current_period_end')->nullable();
$table->timestamp('last_charged_at')->nullable();
$table->timestamp('canceled_at')->nullable();
$table->string('last_reference')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index(['status', 'current_period_end']);
});
}
public function down(): void
{
Schema::dropIfExists('events_pro_subscriptions');
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "ladill-events",
"name": "ladill-qr-plus",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #009e6b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #db5443;
}
.cls-3 {
fill: #00a3d7;
}
</style>
</defs>
<path class="cls-3" d="M1.7,170.6c0,9,.1,18.2,0,27.2-.1,8.8,4.3,15,13.8,19.3,17.3,7.5,34.4,15.2,51.7,22.8,69,30.6,138.2,61.1,207.1,92,16.9,7.6,37.1-.3,36.9-17.8v-54c0-17.9-4.5-15.2-14.4-19.4-47-20.7-94-41.6-141-62.4-39-17.3-78.2-34.2-117-51.9C19.9,117.7,0,128.1,1.3,144.4c.6,8.8.1,17.6.1,26.3h.3Z"/>
<path class="cls-1" d="M194.6,25.4c9,0,18.2.1,27.2,0,8.8-.1,15,4.3,19.3,13.8,7.5,17.3,15.2,34.4,22.8,51.7,30.6,69.1,60.9,138.3,91.9,207.2,7.6,16.9-.3,37.1-17.8,36.9h-54c-17.9,0-15.2-4.5-19.4-14.4-20.7-47-41.6-94-62.4-141-17.3-39-34.2-78.2-51.9-117-8.7-18.8,1.8-38.7,18.1-37.4,8.8.6,17.6.1,26.3.1h0Z"/>
<path class="cls-2" d="M194.9,25.3c-9,0-18.2.1-27.2,0-8.8-.1-15,4.3-19.3,13.8-7.5,17.3-15.2,34.4-22.8,51.7-30.6,69.1-60.9,138.3-91.9,207.2-7.6,16.9.3,37.1,17.8,36.9h54c17.9,0,15.2-4.5,19.4-14.4,20.7-47,41.6-94,62.4-141,17.3-39,34.2-78.2,51.9-117,8.7-18.8-1.8-38.7-18.1-37.4-8.8.6-17.6.1-26.3.1h0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #5cc45b;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #96c;
}
.cls-3 {
fill: #7b9594;
}
</style>
</defs>
<path class="cls-1" d="M347.3,272.3c-50.4,84.4-159.2,112.7-244.6,63.8C17.4,286.6-12.4,177.8,35.6,92.4l311.7,179.8Z"/>
<path class="cls-2" d="M347.3,87.6C297,3.2,188.1-25.1,102.7,24.3,17.4,73.2-12.4,182.1,35.6,267.5L347.3,87.6Z"/>
<path class="cls-3" d="M35.6,92.4c-6,10.6-10.7,21.6-14.3,32.7,0,.3-.2.6-.3.9-.3.9-.6,1.8-.8,2.7-.3,1-.6,1.9-.8,2.9,0,.3-.1.5-.2.8-3.4,12.3-5.4,24.9-6.1,37.7,0,0,0,0,0,.1,0,1.3-.1,2.5-.2,3.8,0,.3,0,.7,0,1,0,1,0,1.9,0,2.9,0,.7,0,1.3,0,2,0,.7,0,1.3,0,2,0,1,0,1.9,0,2.9,0,.3,0,.7,0,1,0,1.2,0,2.5.2,3.7,0,0,0,.1,0,.2.6,9.9,1.9,19.8,4.1,29.4,0,.2,0,.3.1.5.2,1.1.5,2.2.8,3.3.2.6.3,1.3.5,1.9.2.6.3,1.2.5,1.8,3.8,14,9.3,27.7,16.7,40.9l151.7-87.5L35.6,92.4Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-35
View File
@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #d7d135;
}
.cls-1, .cls-2, .cls-3, .cls-4, .cls-5 {
stroke-width: 0px;
}
.cls-2 {
fill: #8c67d5;
}
.cls-3 {
fill: #3d85a4;
}
.cls-4 {
fill: #6576bd;
}
.cls-5 {
fill: #b29c85;
}
</style>
</defs>
<path class="cls-2" d="M168.1,0C74,6.3-.4,84.6.2,179.8h0c0,95.1,74.4,173.4,167.9,179.8V0Z"/>
<path class="cls-3" d="M.2,192.1c6.3,94.1,84.6,168.5,179.8,167.9h0c95.1,0,173.4-74.4,179.8-167.9H.2Z"/>
<path class="cls-1" d="M.2,167.9C6.5,73.8,84.8-.6,180,0h0C275.1,0,353.4,74.4,359.7,167.9H.2Z"/>
<path class="cls-5" d="M168.1,167.9V.4c-19.4,1.2-37.9,5.4-55.1,12.3-.2,0-.4.2-.7.3-2.1.8-4.1,1.7-6.2,2.7-.7.3-1.4.6-2.1.9-1.6.8-3.2,1.5-4.8,2.3-1,.5-2.1,1-3.1,1.6-1.3.7-2.6,1.4-3.8,2.1-1.2.7-2.5,1.4-3.7,2.1-1.1.6-2.1,1.3-3.2,1.9-1.4.8-2.7,1.7-4,2.5-.9.6-1.9,1.3-2.8,1.9-1.4.9-2.7,1.9-4.1,2.8-.9.7-1.8,1.3-2.7,2-1.3,1-2.6,2-3.9,3-.9.7-1.8,1.5-2.7,2.2-1.2,1-2.4,2-3.6,3.1-.9.8-1.9,1.7-2.8,2.5-1.1,1-2.2,2-3.3,3-1,.9-1.9,1.9-2.9,2.9-1,1-1.9,1.9-2.9,2.9-1,1.1-2,2.2-3,3.3-.8.9-1.7,1.8-2.5,2.8-1,1.2-2.1,2.4-3.1,3.6-.7.9-1.5,1.8-2.2,2.7-1,1.3-2,2.6-3,3.9-.7.9-1.3,1.8-2,2.7-1,1.3-1.9,2.7-2.8,4.1-.6.9-1.3,1.9-1.9,2.8-.9,1.3-1.7,2.7-2.5,4-.7,1.1-1.3,2.1-1.9,3.2-.7,1.2-1.4,2.5-2.1,3.7-.7,1.3-1.4,2.5-2.1,3.8-.5,1-1.1,2-1.6,3.1-.8,1.6-1.6,3.2-2.3,4.8-.3.7-.6,1.4-.9,2.1-.9,2-1.8,4.1-2.7,6.2,0,.2-.2.4-.3.7-6.9,17.2-11.1,35.8-12.3,55.1h167.6Z"/>
<path class="cls-4" d="M168.1,359.5v-167.5H.7c6.1,89.5,78,161.4,167.5,167.5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #ff79b7;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #7b50c4;
}
.cls-3 {
fill: #0094c8;
}
</style>
</defs>
<path class="cls-2" d="M14.1,74.6c3.7,8.2,7.5,16.9,11.1,25.1s9.9,12.2,20.5,12c19.1-.4,37.6,0,56.9-.1,76.1-.1,152.2-.2,228.2.2,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2-85.9.4-129.1-.1-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
<path class="cls-1" d="M63.1,188.9c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,86.6-.4,162.6,0,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
<path class="cls-3" d="M116.1,305.2c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,16.9-.5,92.9-.1,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-33.7.3-85.7.4-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+5 -10
View File
@@ -3,23 +3,18 @@
<defs>
<style>
.cls-1 {
fill: #007e7d;
fill: #f6398a;
}
.cls-1, .cls-2, .cls-3 {
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #7b5c84;
}
.cls-3 {
fill: #f6398a;
fill: #007e7d;
}
</style>
</defs>
<path class="cls-1" d="M357,144.9c13.6-60.9-29.8-126.9-89-126.9s-56.6,15.3-73.7,39.6l127.6,152.5c14.8-19.8,30-40.8,35.1-65.2Z"/>
<path class="cls-3" d="M180.7,81.5c-9.8-11.1-19.2-22.6-29-33.6-6.8-8.1-13.2-17-23-22.1-10.6-5.1-23.9-7.7-36.2-7.7-59.1-.1-102.6,65.9-89.8,126.8,6.4,30.7,28.5,55.4,45.6,79.2,22.1,30.7,65.6,74.1,96.3,103.5,20,19.2,50.7,19.2,70.7,0,28.7-27.5,67.6-66.7,90.8-96.3-44-52.6-112.7-135.1-125.3-149.8h-.1Z"/>
<path class="cls-2" d="M234.3,143.1c-.4-.3-.8-.6-1.1-1-17.5-20.4-34.9-40.5-52.4-60.9-.2-.3-.4-.5-.7-.8-.4.4-.7.9-1.1,1.3-17.5,20.4-34.9,40.5-52.4,60.9-.3.4-.8.7-1.1,1l-73.5,85.1c23,30.3,63.5,70.8,92.6,98.6,20,19.2,50.7,19.2,70.7,0,29.6-28.3,69.8-69,92.8-98.9l-73.7-85.3h0Z"/>
<path class="cls-1" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5c-17.5-20.3-34.9-40.6-52.4-61-9.6-11.2-19.3-22.4-28.9-33.6-7-8.1-13.3-17.2-23.1-22.1-11.2-5.6-24.3-8.1-36.7-8.1C32.8,17.9-10.8,83.4,2.3,144.7c6.5,30.6,28.4,55.4,45.8,79.4,22.2,30.7,65.6,74.1,96.5,103.8,20.2,19.4,50.6,19.4,70.7,0,30.9-29.7,74.3-73.2,96.5-103.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
<path class="cls-2" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5l75.1,86.7c1.3-1.6,2.5-3.3,3.6-4.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+5 -5
View File
@@ -31,10 +31,10 @@
}
</style>
</defs>
<path class="cls-4" d="M52.3,11.7h0c28.9,0,52.3,23.4,52.3,52.3v229.7c0,28.9-23.4,52.3-52.3,52.3h0c-28.9,0-52.3-23.4-52.3-52.3V64C0,35.1,23.4,11.7,52.3,11.7Z"/>
<path class="cls-1" d="M304.6,126.2h0c29.2,0,52.9,23.7,52.9,52.9v114c0,29.2-23.7,52.9-52.9,52.9h0c-29.2,0-52.9-23.7-52.9-52.9v-114c0-29.2,23.7-52.9,52.9-52.9Z"/>
<path class="cls-6" d="M304.6,126.2H51.7c-29,0-51.7,23.9-51.7,52.4s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6,0Z"/>
<rect class="cls-4" x="0" y="11.7" width="104.6" height="334.3" rx="52.3" ry="52.3"/>
<rect class="cls-1" x="251.7" y="126.2" width="105.8" height="219.8" rx="52.9" ry="52.9"/>
<path class="cls-6" d="M304.6,126.2H51.7C22.7,126.2,0,150.1,0,178.6s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6Z"/>
<circle class="cls-3" cx="304.1" cy="64" r="52.3"/>
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3h0Z"/>
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9c-26.7,0-48.2,19.7-51.7,45.3Z"/>
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3Z"/>
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9C25,126.8,3.5,146.6,0,172.2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #c1c93d;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #3dc3a4;
}
.cls-3 {
fill: #2a4372;
}
</style>
</defs>
<circle class="cls-3" cx="259.2" cy="76.3" r="76.3"/>
<path class="cls-1" d="M168.8,360v-189.2c-79.5,0-144.3,64.7-144.3,144.3v45h144.3Z"/>
<path class="cls-2" d="M187.1,170.8v189.2c79.5,0,144.3-64.7,144.3-144.3v-45h-144.3Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 638 B

Some files were not shown because too many files have changed in this diff Show More