From df00deb0dfb84107e7f2ba4688225cc4940be819 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 6 Feb 2026 13:21:11 +0100 Subject: [PATCH] Fix endcustomer package allocation and event create gating --- .../Api/Tenant/EventController.php | 7 ++ .../Checkout/CheckoutAssignmentService.php | 19 ++-- .../Packages/PackageLimitEvaluator.php | 33 ++++++- ..._tenant_packages_from_purchase_history.php | 76 ++++++++++++++++ resources/js/admin/mobile/DashboardPage.tsx | 16 +++- resources/js/admin/mobile/EventsPage.tsx | 16 +++- .../mobile/__tests__/EventsPage.test.tsx | 89 +++++++++++++++++++ .../admin/mobile/components/MobileShell.tsx | 16 +++- .../CheckoutSessionLocalConfirmationTest.php | 62 +++++++++++++ tests/Feature/EventControllerTest.php | 61 +++++++++++++ .../Services/PackageLimitEvaluatorTest.php | 28 ++++++ 11 files changed, 409 insertions(+), 14 deletions(-) create mode 100644 database/migrations/2026_02_06_131537_backfill_endcustomer_tenant_packages_from_purchase_history.php diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 62dd7219..0c1cfbf3 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -110,7 +110,14 @@ class EventController extends Controller $tenantPackage = $tenant->tenantPackages() ->with('package') ->where('active', true) + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer')) + ->withCount('eventPackages') + ->orderBy('event_packages_count') ->orderByDesc('purchased_at') + ->orderByDesc('id') ->first(); $package = null; diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 1d115c62..f0ce13c1 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -111,21 +111,26 @@ class CheckoutAssignmentService ]); } } else { - $tenantPackage = TenantPackage::updateOrCreate( - [ + if ($purchase->wasRecentlyCreated) { + $tenantPackage = TenantPackage::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - ], - [ 'price' => round($price, 2), 'active' => true, 'purchased_at' => now(), 'expires_at' => $this->resolveExpiry($package, $tenant), - ] - ); + ]); + } else { + $tenantPackage = TenantPackage::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->orderByDesc('purchased_at') + ->orderByDesc('id') + ->first(); + } } - if ($package->type !== 'reseller') { + if ($package->type !== 'reseller' && $tenantPackage) { $tenant->forceFill([ 'subscription_status' => 'active', 'subscription_expires_at' => $tenantPackage->expires_at, diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 98ae5d7c..65363a87 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -13,13 +13,38 @@ class PackageLimitEvaluator public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array { - $hasEndcustomerPackage = $tenant->tenantPackages() + $activeEndcustomerPackages = $tenant->tenantPackages() ->where('active', true) + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer')) - ->exists(); + ->withCount(['eventPackages' => function ($query) use ($tenant) { + $query->whereHas('event', fn ($eventQuery) => $eventQuery->where('tenant_id', $tenant->id)); + }]) + ->get(); - if ($hasEndcustomerPackage) { - return null; + if ($activeEndcustomerPackages->isNotEmpty()) { + $hasAvailableEndcustomerPackage = $activeEndcustomerPackages + ->contains(fn ($tenantPackage) => (int) ($tenantPackage->event_packages_count ?? 0) < 1); + + if ($hasAvailableEndcustomerPackage) { + return null; + } + + return [ + 'code' => 'event_limit_exceeded', + 'title' => __('api.packages.event_limit_exceeded.title'), + 'message' => __('api.packages.event_limit_exceeded.message'), + 'status' => 402, + 'meta' => [ + 'scope' => 'events', + 'used' => (int) $activeEndcustomerPackages->count(), + 'limit' => (int) $activeEndcustomerPackages->count(), + 'remaining' => 0, + 'source' => 'endcustomer_packages', + ], + ]; } if ($tenant->hasEventAllowanceFor($includedPackageSlug)) { diff --git a/database/migrations/2026_02_06_131537_backfill_endcustomer_tenant_packages_from_purchase_history.php b/database/migrations/2026_02_06_131537_backfill_endcustomer_tenant_packages_from_purchase_history.php new file mode 100644 index 00000000..fe24445b --- /dev/null +++ b/database/migrations/2026_02_06_131537_backfill_endcustomer_tenant_packages_from_purchase_history.php @@ -0,0 +1,76 @@ +join('packages', 'packages.id', '=', 'package_purchases.package_id') + ->where('packages.type', 'endcustomer') + ->whereNotNull('package_purchases.tenant_id') + ->where(function ($query) { + $query->whereNull('package_purchases.refunded') + ->orWhere('package_purchases.refunded', false); + }) + ->orderBy('package_purchases.purchased_at') + ->orderBy('package_purchases.id') + ->get([ + 'package_purchases.tenant_id', + 'package_purchases.package_id', + 'package_purchases.price', + 'package_purchases.purchased_at', + ]) + ->groupBy(fn ($row) => "{$row->tenant_id}:{$row->package_id}"); + + foreach ($groupedPurchases as $purchases) { + if ($purchases->isEmpty()) { + continue; + } + + $tenantId = (int) $purchases[0]->tenant_id; + $packageId = (int) $purchases[0]->package_id; + + $existingCount = DB::table('tenant_packages') + ->where('tenant_id', $tenantId) + ->where('package_id', $packageId) + ->count(); + + if ($existingCount >= $purchases->count()) { + continue; + } + + $missingPurchases = $purchases->slice($existingCount)->values(); + + foreach ($missingPurchases as $purchase) { + $purchasedAt = $purchase->purchased_at ? Carbon::parse($purchase->purchased_at) : now(); + $expiresAt = $purchasedAt->copy()->addYear(); + + DB::table('tenant_packages')->insert([ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'price' => $purchase->price ?? 0, + 'purchased_at' => $purchasedAt, + 'expires_at' => $expiresAt, + 'used_events' => 0, + 'active' => $expiresAt->isFuture(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + // + } +}; diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 9cdebbe8..2bd3c463 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -90,6 +90,20 @@ function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean { return limitMaxEvents > usedEvents; } +function endcustomerHasRemainingEvents(pkg: TenantPackageSummary): boolean { + if (pkg.package_type !== 'endcustomer') { + return false; + } + + const linkedEvents = toNumber(pkg.linked_events_count) ?? 0; + + return linkedEvents < 1; +} + +function packageHasRemainingEvents(pkg: TenantPackageSummary): boolean { + return resellerHasRemainingEvents(pkg) || endcustomerHasRemainingEvents(pkg); +} + // --- TAMAGUI-ALIGNED PRIMITIVES --- function DashboardCard({ @@ -252,7 +266,7 @@ export default function MobileDashboardPage() { } const activePackages = collectActivePackages(packagesOverview ?? null); - return activePackages.some((pkg) => resellerHasRemainingEvents(pkg)); + return activePackages.some((pkg) => packageHasRemainingEvents(pkg)); }, [canManageEvents, isSuperAdmin, isMember, packagesLoading, packagesOverview]); const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index b3a7ef08..8ddb4ffe 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -99,7 +99,7 @@ export default function MobileEventsPage() { } const activePackages = collectActivePackages(packagesOverview); - return activePackages.some((pkg) => resellerHasRemainingEvents(pkg)); + return activePackages.some((pkg) => packageHasRemainingEvents(pkg)); }, [isMember, isSuperAdmin, packagesLoading, packagesOverview]); return ( @@ -570,6 +570,20 @@ function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean { return limitMaxEvents > usedEvents; } +function endcustomerHasRemainingEvents(pkg: TenantPackageSummary): boolean { + if (pkg.package_type !== 'endcustomer') { + return false; + } + + const linkedEvents = toNumber(pkg.linked_events_count) ?? 0; + + return linkedEvents < 1; +} + +function packageHasRemainingEvents(pkg: TenantPackageSummary): boolean { + return resellerHasRemainingEvents(pkg) || endcustomerHasRemainingEvents(pkg); +} + function resolveEventSearchName(name: TenantEvent['name'], t: (key: string) => string): string { if (typeof name === 'string') return name; if (name && typeof name === 'object') { diff --git a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx index cea30022..9a83b8da 100644 --- a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx @@ -79,6 +79,7 @@ vi.mock('../../api', () => ({ purchased_at: null, expires_at: null, package_limits: null, + linked_events_count: 0, }, ], activePackage: { @@ -95,6 +96,7 @@ vi.mock('../../api', () => ({ purchased_at: null, expires_at: null, package_limits: null, + linked_events_count: 0, }, }), })); @@ -295,6 +297,49 @@ describe('MobileEventsPage', () => { expect(await screen.findByText('New event')).toBeInTheDocument(); }); + it('shows the create button for available endcustomer packages', async () => { + vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({ + packages: [ + { + id: 3, + package_id: 3, + package_name: 'Classic', + package_type: 'endcustomer', + included_package_slug: null, + active: true, + used_events: 0, + remaining_events: null, + price: 120, + currency: 'EUR', + purchased_at: null, + expires_at: null, + package_limits: null, + linked_events_count: 0, + }, + ], + activePackage: { + id: 3, + package_id: 3, + package_name: 'Classic', + package_type: 'endcustomer', + included_package_slug: null, + active: true, + used_events: 0, + remaining_events: null, + price: 120, + currency: 'EUR', + purchased_at: null, + expires_at: null, + package_limits: null, + linked_events_count: 0, + }, + }); + + render(); + + expect(await screen.findByText('New event')).toBeInTheDocument(); + }); + it('hides the create button when no remaining events are available', async () => { vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({ packages: [ @@ -336,4 +381,48 @@ describe('MobileEventsPage', () => { expect(await screen.findByText('Demo Event')).toBeInTheDocument(); expect(screen.queryByText('New event')).not.toBeInTheDocument(); }); + + it('hides the create button for consumed endcustomer packages', async () => { + vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({ + packages: [ + { + id: 4, + package_id: 4, + package_name: 'Classic', + package_type: 'endcustomer', + included_package_slug: null, + active: true, + used_events: 0, + remaining_events: null, + price: 120, + currency: 'EUR', + purchased_at: null, + expires_at: null, + package_limits: null, + linked_events_count: 1, + }, + ], + activePackage: { + id: 4, + package_id: 4, + package_name: 'Classic', + package_type: 'endcustomer', + included_package_slug: null, + active: true, + used_events: 0, + remaining_events: null, + price: 120, + currency: 'EUR', + purchased_at: null, + expires_at: null, + package_limits: null, + linked_events_count: 1, + }, + }); + + render(); + + expect(await screen.findByText('Demo Event')).toBeInTheDocument(); + expect(screen.queryByText('New event')).not.toBeInTheDocument(); + }); }); diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index 903e2ab5..a2c704f8 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -85,6 +85,20 @@ function resellerHasRemainingEvents(pkg: TenantPackageSummary): boolean { return limitMaxEvents > usedEvents; } +function endcustomerHasRemainingEvents(pkg: TenantPackageSummary): boolean { + if (pkg.package_type !== 'endcustomer') { + return false; + } + + const linkedEvents = toNumber(pkg.linked_events_count) ?? 0; + + return linkedEvents < 1; +} + +function packageHasRemainingEvents(pkg: TenantPackageSummary): boolean { + return resellerHasRemainingEvents(pkg) || endcustomerHasRemainingEvents(pkg); +} + export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { const { events, activeEvent, selectEvent } = useEventContext(); const { user } = useAuth(); @@ -219,7 +233,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head } const activePackages = collectActivePackages(packagesOverview ?? null); - return activePackages.some((pkg) => resellerHasRemainingEvents(pkg)); + return activePackages.some((pkg) => packageHasRemainingEvents(pkg)); }, [isMember, isSuperAdmin, packagesLoading, packagesOverview]); // --- CONTEXT PILL --- diff --git a/tests/Feature/CheckoutSessionLocalConfirmationTest.php b/tests/Feature/CheckoutSessionLocalConfirmationTest.php index 3fbc0b22..360c1f41 100644 --- a/tests/Feature/CheckoutSessionLocalConfirmationTest.php +++ b/tests/Feature/CheckoutSessionLocalConfirmationTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Checkout\CheckoutSessionService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -19,6 +20,10 @@ class CheckoutSessionLocalConfirmationTest extends TestCase public function test_local_confirmation_marks_checkout_completed(): void { $this->app['env'] = 'local'; + Config::set('checkout.providers', [ + CheckoutSession::PROVIDER_LEMONSQUEEZY, + CheckoutSession::PROVIDER_FREE, + ]); Mail::fake(); Notification::fake(); @@ -70,4 +75,61 @@ class CheckoutSessionLocalConfirmationTest extends TestCase 'active' => true, ]); } + + public function test_local_confirmation_creates_new_endcustomer_tenant_package_per_purchase(): void + { + $this->app['env'] = 'local'; + Config::set('checkout.providers', [ + CheckoutSession::PROVIDER_LEMONSQUEEZY, + CheckoutSession::PROVIDER_FREE, + ]); + Mail::fake(); + Notification::fake(); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create(); + $package = Package::factory()->create([ + 'type' => 'endcustomer', + 'lemonsqueezy_variant_id' => 'pri_456', + 'lemonsqueezy_product_id' => 'pro_456', + 'price' => 120, + ]); + + $sessions = app(CheckoutSessionService::class); + + $firstSession = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); + $sessions->selectProvider($firstSession, CheckoutSession::PROVIDER_LEMONSQUEEZY); + + $this->actingAs($user); + $this->withSession(['_token' => 'test-token']); + + $this->postJson( + route('checkout.session.confirm', $firstSession), + [ + 'order_id' => 'ord_456_a', + 'checkout_id' => 'chk_456_a', + ], + ['X-CSRF-TOKEN' => 'test-token'] + )->assertOk(); + + $secondSession = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); + $sessions->selectProvider($secondSession, CheckoutSession::PROVIDER_LEMONSQUEEZY); + + $this->postJson( + route('checkout.session.confirm', $secondSession), + [ + 'order_id' => 'ord_456_b', + 'checkout_id' => 'chk_456_b', + ], + ['X-CSRF-TOKEN' => 'test-token'] + )->assertOk(); + + $this->assertSame( + 2, + \App\Models\TenantPackage::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->count() + ); + } } diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 5f2aee8f..505631b4 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -158,6 +158,67 @@ class EventControllerTest extends TenantTestCase ->assertJsonValidationErrors(['accepted_waiver']); } + public function test_create_event_uses_available_endcustomer_packages_and_stops_when_all_are_consumed(): void + { + $tenant = $this->tenant; + $eventType = EventType::factory()->create(); + $package = Package::factory()->endcustomer()->create(['max_photos' => 100]); + + $firstTenantPackage = TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'active' => true, + 'purchased_at' => now()->subDay(), + 'expires_at' => now()->addYear(), + ]); + + $secondTenantPackage = TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + ]); + + $firstResponse = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [ + 'name' => 'First Endcustomer Event', + 'slug' => 'first-endcustomer-event', + 'event_date' => Carbon::now()->addDays(10)->toDateString(), + 'event_type_id' => $eventType->id, + 'accepted_waiver' => true, + ]); + $firstResponse->assertStatus(201); + + $firstEvent = Event::query()->where('slug', 'first-endcustomer-event')->firstOrFail(); + $firstEventPackage = EventPackage::query()->where('event_id', $firstEvent->id)->firstOrFail(); + + $secondResponse = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [ + 'name' => 'Second Endcustomer Event', + 'slug' => 'second-endcustomer-event', + 'event_date' => Carbon::now()->addDays(11)->toDateString(), + 'event_type_id' => $eventType->id, + 'accepted_waiver' => true, + ]); + $secondResponse->assertStatus(201); + + $secondEvent = Event::query()->where('slug', 'second-endcustomer-event')->firstOrFail(); + $secondEventPackage = EventPackage::query()->where('event_id', $secondEvent->id)->firstOrFail(); + + $this->assertContains($firstEventPackage->tenant_package_id, [$firstTenantPackage->id, $secondTenantPackage->id]); + $this->assertContains($secondEventPackage->tenant_package_id, [$firstTenantPackage->id, $secondTenantPackage->id]); + $this->assertNotSame($firstEventPackage->tenant_package_id, $secondEventPackage->tenant_package_id); + + $thirdResponse = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [ + 'name' => 'Third Endcustomer Event', + 'slug' => 'third-endcustomer-event', + 'event_date' => Carbon::now()->addDays(12)->toDateString(), + 'event_type_id' => $eventType->id, + 'accepted_waiver' => true, + ]); + $thirdResponse->assertStatus(402) + ->assertJsonPath('error.code', 'event_limit_exceeded'); + } + public function test_create_event_with_reseller_package_limits_events(): void { $tenant = $this->tenant; diff --git a/tests/Unit/Services/PackageLimitEvaluatorTest.php b/tests/Unit/Services/PackageLimitEvaluatorTest.php index 9a9e83a2..36ea7d45 100644 --- a/tests/Unit/Services/PackageLimitEvaluatorTest.php +++ b/tests/Unit/Services/PackageLimitEvaluatorTest.php @@ -65,6 +65,34 @@ class PackageLimitEvaluatorTest extends TestCase $this->assertSame(0, $violation['meta']['remaining']); } + public function test_assess_event_creation_returns_violation_when_all_endcustomer_packages_are_consumed(): void + { + $tenant = Tenant::factory()->create(); + $package = Package::factory()->endcustomer()->create(); + + $tenantPackage = TenantPackage::factory()->for($tenant)->for($package)->create([ + 'active' => true, + 'expires_at' => now()->addMonth(), + ]); + + $event = Event::factory()->for($tenant)->create(); + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'tenant_package_id' => $tenantPackage->id, + 'purchased_price' => 0, + 'purchased_at' => now(), + 'gallery_expires_at' => now()->addDays(7), + ]); + + $violation = $this->evaluator->assessEventCreation($tenant, null); + + $this->assertNotNull($violation); + $this->assertSame('event_limit_exceeded', $violation['code']); + $this->assertSame('endcustomer_packages', $violation['meta']['source']); + $this->assertSame(0, $violation['meta']['remaining']); + } + public function test_assess_photo_upload_returns_violation_when_photo_limit_reached(): void { $package = Package::factory()->endcustomer()->create([