feat(packages): implement package-based business model

This commit is contained in:
Codex Agent
2025-09-26 22:13:56 +02:00
parent 6fc36ebaf4
commit 0a643c3e4d
54 changed files with 3301 additions and 282 deletions

View File

@@ -0,0 +1,127 @@
<?php
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\Package;
use App\Models\Event;
use App\Models\User;
use App\Models\TenantPackage;
use App\Models\EventPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventControllerTest extends TestCase
{
use RefreshDatabase;
public function test_create_event_with_valid_package_succeeds(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
$response = $this->actingAs($user)
->postJson('/api/v1/tenant/events', [
'name' => 'Test Event',
'slug' => 'test-event',
'date' => '2025-10-01',
'package_id' => $package->id,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('events', [
'tenant_id' => $tenant->id,
'name' => 'Test Event',
'slug' => 'test-event',
]);
$event = Event::latest()->first();
$this->assertDatabaseHas('event_packages', [
'event_id' => $event->id,
'package_id' => $package->id,
]);
$this->assertDatabaseHas('package_purchases', [
'event_id' => $event->id,
'package_id' => $package->id,
'type' => 'endcustomer_event',
'provider_id' => 'manual',
]);
}
public function test_create_event_without_package_fails(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)
->postJson('/api/v1/tenant/events', [
'name' => 'Test Event',
'slug' => 'test-event',
'date' => '2025-10-01',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['package_id']);
}
public function test_create_event_with_reseller_package_limits_events(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
// First event succeeds
$response1 = $this->actingAs($user)
->postJson('/api/v1/tenant/events', [
'name' => 'First Event',
'slug' => 'first-event',
'date' => '2025-10-01',
'package_id' => $package->id, // Use reseller package for event? Adjust if needed
]);
$response1->assertStatus(201);
// Second event fails due to limit
$response2 = $this->actingAs($user)
->postJson('/api/v1/tenant/events', [
'name' => 'Second Event',
'slug' => 'second-event',
'date' => '2025-10-02',
'package_id' => $package->id,
]);
$response2->assertStatus(402)
->assertJson(['error' => 'No available package for event creation']);
}
public function test_upload_exceeds_package_limit_fails(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
EventPackage::factory()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'used_photos' => 0,
]);
$response = $this->actingAs($user)
->postJson("/api/v1/events/{$event->slug}/photos", [
'photo' => 'test-photo.jpg',
]);
$response->assertStatus(402)
->assertJson(['error' => 'Upload limit reached for this event']);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Api\StripeWebhookController;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\EventPackage;
use App\Models\TenantPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Stripe\Webhook;
use Tests\TestCase;
class StripeWebhookTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Mock Stripe secret
config(['services.stripe.webhook_secret' => 'whsec_test_secret']);
}
public function test_handle_payment_intent_succeeded_creates_event_package(): void
{
$tenant = \App\Models\Tenant::factory()->create();
$event = \App\Models\Event::factory()->create(['tenant_id' => $tenant->id]);
$package = Package::factory()->create(['type' => 'endcustomer']);
$payload = [
'id' => 'evt_test',
'type' => 'payment_intent.succeeded',
'data' => [
'object' => [
'id' => 'pi_test',
'metadata' => [
'type' => 'endcustomer_event',
'tenant_id' => (string) $tenant->id,
'event_id' => (string) $event->id,
'package_id' => (string) $package->id,
],
],
],
];
$sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true));
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
'Stripe-Signature' => $sigHeader,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('package_purchases', [
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'type' => 'endcustomer_event',
'provider_id' => 'pi_test',
]);
$this->assertDatabaseHas('event_packages', [
'event_id' => $event->id,
'package_id' => $package->id,
]);
}
public function test_handle_invoice_paid_renews_tenant_package(): void
{
$tenant = \App\Models\Tenant::factory()->create();
$package = Package::factory()->create(['type' => 'reseller']);
$payload = [
'id' => 'evt_test',
'type' => 'invoice.paid',
'data' => [
'object' => [
'subscription' => 'sub_test',
'metadata' => [
'type' => 'reseller_subscription',
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
],
],
],
];
$sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true));
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
'Stripe-Signature' => $sigHeader,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('package_purchases', [
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'type' => 'reseller_subscription',
]);
$tenantPackage = TenantPackage::where('tenant_id', $tenant->id)->first();
$this->assertNotNull($tenantPackage);
$this->assertTrue($tenantPackage->expires_at->isFuture());
}
public function test_webhook_rejects_invalid_signature(): void
{
$payload = ['type' => 'invalid'];
$sigHeader = 'invalid';
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
'Stripe-Signature' => $sigHeader,
]);
$response->assertStatus(400);
}
}

View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { chromium } from 'playwright';
test.describe('Package Flow in Admin PWA', () => {
test('Create event with package and verify limits', async ({ page }) => {
// Assume logged in as tenant admin, navigate to events page
await page.goto('/admin/events');
// Click create event button
await page.click('[data-testid="create-event"]');
await expect(page).toHaveURL(/\/admin\/events\/create/);
// Fill form
await page.fill('[name="name"]', 'Test Package Event');
await page.fill('[name="slug"]', 'test-package-event');
await page.fill('[name="date"]', '2025-10-01');
// Select package from dropdown
await page.selectOption('[name="package_id"]', '1'); // Assume ID 1 is Starter package
await expect(page.locator('[name="package_id"]')).toHaveValue('1');
// Submit
await page.click('[type="submit"]');
await expect(page).toHaveURL(/\/admin\/events/);
// Verify event created and package assigned
await expect(page.locator('text=Test Package Event')).toBeVisible();
await expect(page.locator('text=Starter')).toBeVisible(); // Package name in table
// Check dashboard limits
await page.goto('/admin/dashboard');
await expect(page.locator('text=Remaining Photos')).toContainText('300'); // Starter limit
// Try to create another event to test reseller limit if applicable
// (Skip for endcustomer; assume tenant has reseller package with limit 1)
await page.goto('/admin/events');
await page.click('[data-testid="create-event"]');
await page.fill('[name="name"]', 'Second Event');
await page.fill('[name="slug"]', 'second-event');
await page.fill('[name="date"]', '2025-10-02');
await page.selectOption('[name="package_id"]', '1');
await page.click('[type="submit"]');
// If limit reached, expect error
await expect(page.locator('text=No available package')).toBeVisible();
});
test('Upload blocked when package limit reached in Guest PWA', async ({ page }) => {
// Assume event with package limit 0 created
await page.goto('/e/test-limited-event'); // Slug of event with max_photos = 0
// Navigate to upload
await page.click('text=Upload');
await expect(page).toHaveURL(/\/upload/);
// Expect upload disabled and error message
await expect(page.locator('button:disabled')).toBeVisible(); // Upload button disabled
await expect(page.locator('text=Upload-Limit erreicht')).toBeVisible();
});
});