From 66e6e117de01a6ac3d29a77540d696f2566ceee6 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 10:03:03 +0000 Subject: [PATCH] Add plan-capped Events demo:seed command. Seed live events, registrations, and Pro subscriptions without billing APIs. Co-authored-by: Cursor --- app/Console/Commands/DemoSeedCommand.php | 361 +++++++++++++++++++++++ tests/Feature/DemoSeedCommandTest.php | 131 ++++++++ 2 files changed, 492 insertions(+) create mode 100644 app/Console/Commands/DemoSeedCommand.php create mode 100644 tests/Feature/DemoSeedCommandTest.php diff --git a/app/Console/Commands/DemoSeedCommand.php b/app/Console/Commands/DemoSeedCommand.php new file mode 100644 index 0000000..297dbbb --- /dev/null +++ b/app/Console/Commands/DemoSeedCommand.php @@ -0,0 +1,361 @@ +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; + + $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' => 'Ama Mensah', + 'host_speaker_email' => 'ama.demo@ladill.com', + ], + [ + 'ref' => 'day0-item1', + 'time' => '11:00', + 'title' => 'Breakout: ticketing', + 'description' => 'Paid tiers and badges', + 'location' => 'Room B', + 'host' => 'Kojo Asante', + 'host_speaker_email' => 'kojo.demo@ladill.com', + ], + ], + ]], + ], + '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' => $user->name ?: 'Ladill Demo', + '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' => 'Ama Mensah', + 'email' => 'ama.demo@ladill.com', + '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; + } + + 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)); + + 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' => 'Demo Attendee '.$n, + 'attendee_email' => 'attendee'.$n.'.e'.$eventIndex.'@demo.ladill.com', + 'attendee_phone' => '+23320'.str_pad((string) (($eventIndex * 1000) + $n), 7, '0', STR_PAD_LEFT), + 'badge_fields' => [ + 'company' => 'Demo Co '.$n, + '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], + ], + ); + } +} diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php new file mode 100644 index 0000000..120a031 --- /dev/null +++ b/tests/Feature/DemoSeedCommandTest.php @@ -0,0 +1,131 @@ + 'https://ladill.com/api/billing', + 'billing.api_key' => 'events-billing-key', + 'events.pro.enabled' => true, + 'events.pro.price_minor' => 4900, + 'events.plans.pro.price_minor' => 4900, + 'events.plans.enterprise.price_minor' => 14900, + 'events.free.max_live_events' => 2, + 'events.free.max_tickets_per_month' => 100, + ]); + } + + public function test_free_plan_respects_live_event_and_registration_caps(): void + { + $user = User::factory()->create([ + 'public_id' => (string) Str::uuid(), + 'email' => 'demo-free@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => $user->email, + '--plan' => 'free', + ])->assertSuccessful(); + + $this->assertSame('free', app(SubscriptionService::class)->planKey($user)); + $this->assertNull(ProSubscription::query()->where('user_id', $user->id)->first()); + + $events = QrCode::query() + ->where('user_id', $user->id) + ->where('type', QrCode::TYPE_EVENT) + ->count(); + $regs = QrEventRegistration::query()->where('user_id', $user->id)->count(); + + $this->assertLessThanOrEqual(2, $events); + $this->assertGreaterThan(0, $events); + $this->assertLessThan(100, $regs); + $this->assertSame(0, QrCode::query()->where('user_id', $user->id)->where('type', QrCode::TYPE_ITINERARY)->count()); + } + + public function test_pro_plan_assigns_subscription_and_seeds_paid_payloads(): void + { + $user = User::factory()->create([ + 'public_id' => (string) Str::uuid(), + 'email' => 'demo-pro@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => $user->public_id, + '--plan' => 'pro', + ])->assertSuccessful(); + + $this->assertSame('pro', app(SubscriptionService::class)->planKey($user)); + $sub = ProSubscription::query()->where('user_id', $user->id)->first(); + $this->assertNotNull($sub); + $this->assertTrue($sub->current_period_end->isFuture()); + $this->assertFalse($sub->auto_renew); + + $events = QrCode::query() + ->where('user_id', $user->id) + ->where('type', QrCode::TYPE_EVENT) + ->get(); + $this->assertGreaterThan(2, $events->count()); + $this->assertSame('ticketing', $events->first()->content()['mode'] ?? null); + $this->assertGreaterThan(0, QrCode::query()->where('user_id', $user->id)->where('type', QrCode::TYPE_ITINERARY)->count()); + $this->assertGreaterThan(50, QrEventRegistration::query()->where('user_id', $user->id)->count()); + } + + public function test_command_is_idempotent_and_reset_reseeds(): void + { + $user = User::factory()->create([ + 'public_id' => (string) Str::uuid(), + 'email' => 'demo-enterprise@ladill.com', + ]); + + $this->artisan('demo:seed', ['identity' => $user->email, '--plan' => 'enterprise'])->assertSuccessful(); + $firstEvents = QrCode::query()->where('user_id', $user->id)->where('type', QrCode::TYPE_EVENT)->count(); + $firstRegs = QrEventRegistration::query()->where('user_id', $user->id)->count(); + + $this->artisan('demo:seed', ['identity' => $user->email, '--plan' => 'enterprise'])->assertSuccessful(); + $this->assertSame($firstEvents, QrCode::query()->where('user_id', $user->id)->where('type', QrCode::TYPE_EVENT)->count()); + $this->assertSame($firstRegs, QrEventRegistration::query()->where('user_id', $user->id)->count()); + + $this->artisan('demo:seed', [ + 'identity' => $user->email, + '--plan' => 'free', + '--reset' => true, + ])->assertSuccessful(); + + $this->assertSame('free', app(SubscriptionService::class)->planKey($user->fresh())); + $this->assertLessThanOrEqual(2, QrCode::query()->where('user_id', $user->id)->where('type', QrCode::TYPE_EVENT)->count()); + $this->assertLessThan(100, QrEventRegistration::query()->where('user_id', $user->id)->count()); + } + + public function test_creates_mirror_when_enough_identity_options_given(): void + { + $publicId = (string) Str::uuid(); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-new@ladill.com', + '--plan' => 'free', + '--public-id' => $publicId, + '--email' => 'demo-new@ladill.com', + '--name' => 'Demo New', + ])->assertSuccessful(); + + $user = User::query()->where('public_id', $publicId)->first(); + $this->assertNotNull($user); + $this->assertSame('demo-new@ladill.com', $user->email); + } +}