coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.

This commit is contained in:
Codex Agent
2025-11-09 20:26:50 +01:00
parent f3c44be76d
commit 082b78cd43
80 changed files with 4855 additions and 435 deletions

View File

@@ -0,0 +1,72 @@
<?php
namespace Tests\Feature\Api\Marketing;
use App\Models\Coupon;
use App\Models\Package;
use App\Services\Paddle\PaddleDiscountService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class CouponPreviewTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_guest_can_preview_coupon(): void
{
$package = Package::factory()->create([
'paddle_price_id' => 'pri_test',
'price' => 100,
]);
$coupon = Coupon::factory()->create([
'code' => 'SAVE20',
'paddle_discount_id' => 'dsc_123',
'per_customer_limit' => null,
]);
$coupon->packages()->attach($package);
$this->instance(PaddleDiscountService::class, Mockery::mock(PaddleDiscountService::class, function ($mock) {
$mock->shouldReceive('previewDiscount')->andReturn([
'totals' => [
'currency_code' => 'EUR',
'subtotal' => 10000,
'discount' => 2000,
'tax' => 0,
'total' => 8000,
],
]);
}));
$response = $this->postJson(route('api.v1.marketing.coupons.preview'), [
'package_id' => $package->id,
'code' => 'save20',
]);
$response->assertOk()
->assertJsonPath('coupon.code', 'SAVE20');
$this->assertEquals(80.0, (float) $response->json('pricing.total'));
}
public function test_invalid_coupon_returns_validation_error(): void
{
$package = Package::factory()->create([
'paddle_price_id' => 'pri_test_invalid',
]);
$this->postJson(route('api.v1.marketing.coupons.preview'), [
'package_id' => $package->id,
'code' => 'UNKNOWN',
])->assertUnprocessable()
->assertJsonValidationErrors('code');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Console;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CouponExportCommandTest extends TestCase
{
use RefreshDatabase;
public function test_export_command_creates_csv(): void
{
Storage::fake('local');
$coupon = Coupon::factory()->create([
'code' => 'FLASH20',
]);
$package = Package::factory()->create();
$tenant = Tenant::factory()->create();
CouponRedemption::factory()->create([
'coupon_id' => $coupon->id,
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'status' => CouponRedemption::STATUS_SUCCESS,
'amount_discounted' => 25,
'redeemed_at' => now(),
]);
$path = 'reports/test-coupons.csv';
$this->artisan('coupons:export', [
'--days' => 7,
'--path' => $path,
])->assertExitCode(0);
Storage::disk('local')->assertExists($path);
$contents = Storage::disk('local')->get($path);
$this->assertStringContainsString('FLASH20', $contents);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Models\Coupon;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PaddleCheckoutControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_authenticated_user_can_create_checkout_with_coupon(): void
{
$tenant = Tenant::factory()->create([
'paddle_customer_id' => 'cus_123',
]);
$user = User::factory()->for($tenant)->create();
$package = Package::factory()->create([
'paddle_price_id' => 'pri_123',
'paddle_product_id' => 'pro_123',
'price' => 120,
]);
$coupon = Coupon::factory()->create([
'code' => 'SAVE15',
'paddle_discount_id' => 'dsc_123',
]);
$coupon->packages()->attach($package);
$couponServiceMock = Mockery::mock(CouponService::class);
$couponServiceMock->shouldReceive('preview')
->once()
->andReturn([
'coupon' => $coupon,
'pricing' => [
'currency' => 'EUR',
'subtotal' => 120.0,
'discount' => 18.0,
'tax' => 0,
'total' => 102.0,
'formatted' => [
'subtotal' => '€120.00',
'discount' => '-€18.00',
'tax' => '€0.00',
'total' => '€102.00',
],
'breakdown' => [],
],
'source' => 'manual',
]);
$this->instance(CouponService::class, $couponServiceMock);
$paddleServiceMock = Mockery::mock(PaddleCheckoutService::class);
$paddleServiceMock->shouldReceive('createCheckout')
->once()
->andReturn([
'checkout_url' => 'https://example.com/checkout/test',
'id' => 'chk_123',
]);
$this->instance(PaddleCheckoutService::class, $paddleServiceMock);
$this->be($user);
$response = $this->postJson(route('paddle.checkout.create'), [
'package_id' => $package->id,
'coupon_code' => 'SAVE15',
]);
$response->assertOk()
->assertJsonPath('checkout_url', 'https://example.com/checkout/test');
$this->assertDatabaseHas('checkout_sessions', [
'package_id' => $package->id,
'coupon_code' => 'SAVE15',
]);
}
}

64
tests/Unit/CouponTest.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
namespace Tests\Unit;
use App\Models\Coupon;
use App\Models\Package;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CouponTest extends TestCase
{
use RefreshDatabase;
public function test_it_evaluates_active_state(): void
{
$coupon = Coupon::factory()->create([
'starts_at' => now()->subDay(),
'ends_at' => now()->addDay(),
'usage_limit' => 1,
'redemptions_count' => 0,
]);
$this->assertTrue($coupon->isCurrentlyActive());
$coupon->update(['redemptions_count' => 1]);
$this->assertFalse($coupon->refresh()->isCurrentlyActive());
$coupon->update([
'usage_limit' => null,
'starts_at' => now()->addDay(),
'redemptions_count' => 0,
]);
$this->assertFalse($coupon->fresh()->isCurrentlyActive());
}
public function test_it_checks_package_applicability(): void
{
$coupon = Coupon::factory()->create();
$packageA = Package::factory()->create();
$packageB = Package::factory()->create();
$this->assertTrue($coupon->appliesToPackage($packageA));
$coupon->packages()->sync([$packageA->getKey()]);
$this->assertTrue($coupon->fresh()->appliesToPackage($packageA));
$this->assertFalse($coupon->appliesToPackage($packageB));
}
public function test_remaining_usage_calculation(): void
{
$coupon = Coupon::factory()->create([
'usage_limit' => 10,
'per_customer_limit' => 2,
'redemptions_count' => 4,
]);
$this->assertSame(6, $coupon->remainingUsages());
$this->assertSame(2, $coupon->remainingUsages(0));
$this->assertSame(1, $coupon->remainingUsages(1));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Unit;
use App\Models\Tenant;
use App\Support\TenantRequestResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Tests\TestCase;
class TenantRequestResolverTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_tenant_from_request_attribute(): void
{
$tenant = Tenant::factory()->make();
$request = Request::create('/api/tenant/test', 'GET');
$request->attributes->set('tenant', $tenant);
$resolved = TenantRequestResolver::resolve($request);
$this->assertSame($tenant, $resolved);
}
public function test_it_finds_tenant_using_identifier(): void
{
$tenant = Tenant::factory()->create();
$request = Request::create('/api/tenant/test', 'GET');
$request->attributes->set('tenant_id', $tenant->id);
$resolved = TenantRequestResolver::resolve($request);
$this->assertTrue($tenant->is($resolved));
}
public function test_it_throws_when_tenant_cannot_be_resolved(): void
{
$this->expectException(HttpResponseException::class);
$request = Request::create('/api/tenant/test', 'GET');
TenantRequestResolver::resolve($request);
}
}