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([