diff --git a/app/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php index f96437df..afbfe4e5 100644 --- a/app/Jobs/CreateAnystackLicenseJob.php +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Enums\Subscription; +use App\Models\License; use App\Models\User; use App\Notifications\LicenseKeyGenerated; use Illuminate\Bus\Queueable; @@ -11,7 +12,6 @@ use Illuminate\Http\Client\PendingRequest; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; class CreateAnystackLicenseJob implements ShouldQueue @@ -21,6 +21,7 @@ class CreateAnystackLicenseJob implements ShouldQueue public function __construct( public User $user, public Subscription $subscription, + public ?string $subscriptionItemId = null, public ?string $firstName = null, public ?string $lastName = null, ) {} @@ -34,12 +35,20 @@ public function handle(): void $this->user->save(); } - $license = $this->createLicense($this->user->anystack_contact_id); + $licenseData = $this->createLicense($this->user->anystack_contact_id); - Cache::put($this->user->email.'.license_key', $license['key'], now()->addDay()); + $license = License::create([ + 'user_id' => $this->user->id, + 'subscription_item_id' => $this->subscriptionItemId, + 'policy_name' => $this->subscription->value, + 'key' => $licenseData['key'], + 'expires_at' => $licenseData['expires_at'], + 'created_at' => $licenseData['created_at'], + 'updated_at' => $licenseData['updated_at'], + ]); $this->user->notify(new LicenseKeyGenerated( - $license['key'], + $license->key, $this->subscription, $this->firstName )); diff --git a/app/Jobs/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php index 022a1aab..e6634d20 100644 --- a/app/Jobs/HandleCustomerSubscriptionCreatedJob.php +++ b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php @@ -10,6 +10,7 @@ use Illuminate\Queue\SerializesModels; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookHandled; +use Laravel\Cashier\SubscriptionItem; use Stripe\Subscription; class HandleCustomerSubscriptionCreatedJob implements ShouldQueue @@ -38,6 +39,10 @@ public function handle(): void } $subscriptionPlan = \App\Enums\Subscription::fromStripeSubscription($stripeSubscription); + $cashierSubscriptionItemId = SubscriptionItem::query() + ->where('stripe_id', $stripeSubscription->items->first()->id) + ->first() + ->id; $nameParts = explode(' ', $user->name ?? '', 2); $firstName = $nameParts[0] ?: null; @@ -46,6 +51,7 @@ public function handle(): void dispatch(new CreateAnystackLicenseJob( $user, $subscriptionPlan, + $cashierSubscriptionItemId, $firstName, $lastName, )); diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php index 6a49fb35..66517e25 100644 --- a/app/Livewire/OrderSuccess.php +++ b/app/Livewire/OrderSuccess.php @@ -3,7 +3,7 @@ namespace App\Livewire; use App\Enums\Subscription; -use Illuminate\Support\Facades\Cache; +use App\Models\User; use Laravel\Cashier\Cashier; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -63,11 +63,21 @@ private function loadLicenseKey(): ?string return null; } - if ($licenseKey = Cache::get($this->email.'.license_key')) { - session()->put($this->sessionKey('license_key'), $licenseKey); + $user = User::where('email', $this->email)->first(); + + if (! $user) { + return null; } - return $licenseKey; + $license = $user->licenses()->latest()->first(); + + if (! $license) { + return null; + } + + session()->put($this->sessionKey('license_key'), $license->key); + + return $license->key; } private function loadSubscription(): ?Subscription diff --git a/app/Models/License.php b/app/Models/License.php new file mode 100644 index 00000000..8f5a5888 --- /dev/null +++ b/app/Models/License.php @@ -0,0 +1,35 @@ + 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function subscriptionItem(): BelongsTo + { + return $this->belongsTo(SubscriptionItem::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index cffc680c..2d8ef928 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Cashier\Billable; @@ -24,4 +25,12 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; + + /** + * @return HasMany + */ + public function licenses(): HasMany + { + return $this->hasMany(License::class); + } } diff --git a/database/factories/LicenseFactory.php b/database/factories/LicenseFactory.php new file mode 100644 index 00000000..a0f0950e --- /dev/null +++ b/database/factories/LicenseFactory.php @@ -0,0 +1,31 @@ + + */ +class LicenseFactory extends Factory +{ + protected $model = License::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'subscription_item_id' => SubscriptionItem::factory(), + 'policy_name' => fake()->randomElement(Subscription::cases())->value, + 'key' => fake()->uuid(), + 'created_at' => fake()->dateTimeBetween('-1 year', 'now'), + 'updated_at' => fn (array $attrs) => $attrs['created_at'], + 'expires_at' => fn (array $attrs) => Date::parse($attrs['created_at'])->addYear(), + ]; + } +} diff --git a/database/migrations/2025_05_14_013051_create_licenses_table.php b/database/migrations/2025_05_14_013051_create_licenses_table.php new file mode 100644 index 00000000..8f163e04 --- /dev/null +++ b/database/migrations/2025_05_14_013051_create_licenses_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id'); + $table->foreignId('subscription_item_id')->nullable(); + $table->string('policy_name'); + $table->string('key'); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('subscription_item_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('licenses'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index f24bd20c..ab40d840 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,8 +21,8 @@ - - + + diff --git a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php index a4bb989f..9ad84dca 100644 --- a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php +++ b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php @@ -6,20 +6,25 @@ use App\Jobs\CreateAnystackLicenseJob; use App\Models\User; use App\Notifications\LicenseKeyGenerated; +use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Str; use Tests\TestCase; class CreateAnystackLicenseJobTest extends TestCase { use RefreshDatabase; + protected CarbonImmutable $now; + protected function setUp(): void { parent::setUp(); + $this->now = now()->toImmutable(); + Http::fake([ 'https://api.anystack.sh/v1/contacts' => Http::response([ 'data' => [ @@ -27,6 +32,8 @@ protected function setUp(): void 'email' => 'test@example.com', 'first_name' => 'John', 'last_name' => 'Doe', + 'created_at' => $this->now->toIso8601String(), + 'updated_at' => $this->now->toIso8601String(), ], ], 201), @@ -36,6 +43,13 @@ protected function setUp(): void 'key' => 'test-license-key-12345', 'contact_id' => 'contact-123', 'policy_id' => 'policy-123', + 'name' => null, + 'activations' => 0, + 'max_activations' => 10, + 'suspended' => false, + 'expires_at' => $this->now->addYear()->toIso8601String(), + 'created_at' => $this->now->toIso8601String(), + 'updated_at' => $this->now->toIso8601String(), ], ], 201), ]); @@ -44,7 +58,7 @@ protected function setUp(): void } /** @test */ - public function it_creates_contact_and_license_on_anystack() + public function it_creates_a_contact_and_license_on_anystack_via_api() { $user = User::factory()->create([ 'email' => 'test@example.com', @@ -54,6 +68,7 @@ public function it_creates_contact_and_license_on_anystack() $job = new CreateAnystackLicenseJob( $user, Subscription::Max, + null, 'John', 'Doe' ); @@ -83,7 +98,31 @@ public function it_creates_contact_and_license_on_anystack() } /** @test */ - public function it_stores_license_key_in_cache() + public function it_does_not_create_a_contact_when_the_user_already_has_a_contact_id() + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'name' => 'John Doe', + 'anystack_contact_id' => 'contact-123', + ]); + + $job = new CreateAnystackLicenseJob( + $user, + Subscription::Max, + null, + 'John', + 'Doe' + ); + + $job->handle(); + + Http::assertNotSent(function ($request) { + return Str::contains($request->url(), 'https://api.anystack.sh/v1/contacts'); + }); + } + + /** @test */ + public function it_stores_the_license_key_in_database() { $user = User::factory()->create([ 'email' => 'test@example.com', @@ -93,17 +132,55 @@ public function it_stores_license_key_in_cache() $job = new CreateAnystackLicenseJob( $user, Subscription::Max, + null, 'John', 'Doe' ); $job->handle(); - $this->assertEquals('test-license-key-12345', Cache::get('test@example.com.license_key')); + $this->assertDatabaseHas('licenses', [ + 'user_id' => $user->id, + 'subscription_item_id' => null, + 'policy_name' => 'max', + 'key' => 'test-license-key-12345', + 'expires_at' => $this->now->addYear(), + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + } + + /** @test */ + public function the_subscription_item_id_is_filled_when_provided() + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'name' => 'John Doe', + ]); + + $job = new CreateAnystackLicenseJob( + $user, + Subscription::Max, + 123, + 'John', + 'Doe' + ); + + $job->handle(); + + $this->assertDatabaseHas('licenses', [ + 'user_id' => $user->id, + 'subscription_item_id' => 123, + 'policy_name' => 'max', + 'key' => 'test-license-key-12345', + 'expires_at' => $this->now->addYear(), + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); } /** @test */ - public function it_sends_license_key_notification() + public function it_sends_a_license_key_notification() { $user = User::factory()->create([ 'email' => 'test@example.com', @@ -113,6 +190,7 @@ public function it_sends_license_key_notification() $job = new CreateAnystackLicenseJob( $user, Subscription::Max, + null, 'John', 'Doe' ); diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php index ad1955f2..f13e5d43 100644 --- a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -21,15 +21,7 @@ class HandleCustomerSubscriptionCreatedJobTest extends TestCase /** @test */ public function it_dispatches_the_create_anystack_license_job_with_correct_data() { - $mockCustomer = Customer::constructFrom([ - 'id' => 'cus_S9dhoV2rJK2Auy', - 'email' => 'test@example.com', - 'name' => 'John Doe', - ]); - - $this->mockStripeClient($mockCustomer); - - dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + $this->createTestData('John Doe'); Bus::fake(); @@ -54,15 +46,7 @@ public function it_dispatches_the_create_anystack_license_job_with_correct_data( */ public function it_extracts_customer_name_parts_correctly($fullName, $expectedFirstName, $expectedLastName) { - $mockCustomer = Customer::constructFrom([ - 'id' => 'cus_S9dhoV2rJK2Auy', - 'email' => 'test@example.com', - 'name' => $fullName, - ]); - - $this->mockStripeClient($mockCustomer); - - dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + $this->createTestData($fullName); $webhookCall = new WebhookHandled($this->getTestWebhookPayload()); @@ -117,6 +101,37 @@ public function it_fails_when_customer_has_no_email() Bus::assertNotDispatched(CreateAnystackLicenseJob::class); } + protected function createTestData(?string $customerName) + { + $mockCustomer = Customer::constructFrom([ + 'id' => $this->getTestWebhookPayload()['data']['object']['customer'], + 'email' => $email = 'test@example.com', + 'name' => $customerName, + ]); + + $this->mockStripeClient($mockCustomer); + + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + $user = User::query()->where('email', $email)->firstOrFail(); + + $subscription = \Laravel\Cashier\Subscription::factory() + ->for($user, 'user') + ->create([ + 'stripe_id' => $this->getTestWebhookPayload()['data']['object']['id'], + 'stripe_status' => 'active', + 'stripe_price' => $this->getTestWebhookPayload()['data']['object']['items']['data'][0]['price']['id'], + 'quantity' => 1, + ]); + $subscriptionItem = \Laravel\Cashier\SubscriptionItem::factory() + ->for($subscription, 'subscription') + ->create([ + 'stripe_id' => $this->getTestWebhookPayload()['data']['object']['items']['data'][0]['id'], + 'stripe_price' => $this->getTestWebhookPayload()['data']['object']['items']['data'][0]['price']['id'], + 'quantity' => 1, + ]); + } + protected function getTestWebhookPayload(): array { return [ diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php index e81c0574..8733b4d7 100644 --- a/tests/Feature/Livewire/OrderSuccessTest.php +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -4,7 +4,9 @@ use App\Enums\Subscription; use App\Livewire\OrderSuccess; -use Illuminate\Support\Facades\Cache; +use App\Models\License; +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Session; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; @@ -16,6 +18,8 @@ class OrderSuccessTest extends TestCase { + use RefreshDatabase; + protected function setUp(): void { parent::setUp(); @@ -44,16 +48,24 @@ public function it_displays_loading_state_when_no_license_key_is_available() } #[Test] - public function it_displays_license_key_when_available() + public function it_displays_license_key_when_available_in_database() { Session::flush(); - Cache::put('test@example.com.license_key', 'test-license-key-12345'); + $user = User::factory()->create([ + 'email' => 'test@example.com', + ]); + + License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'db-license-key-12345', + 'policy_name' => 'max', + ]); Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) ->assertSet('email', 'test@example.com') - ->assertSet('licenseKey', 'test-license-key-12345') - ->assertSee('test-license-key-12345') + ->assertSet('licenseKey', 'db-license-key-12345') + ->assertSee('db-license-key-12345') ->assertSee('test@example.com') ->assertDontSee('License registration in progress'); } @@ -61,8 +73,6 @@ public function it_displays_license_key_when_available() #[Test] public function it_uses_session_data_when_available() { - Cache::flush(); - $checkoutSessionId = 'cs_test_123'; Session::put("$checkoutSessionId.email", 'session@example.com'); @@ -76,7 +86,7 @@ public function it_uses_session_data_when_available() } #[Test] - public function it_polls_for_updates() + public function it_polls_for_updates_from_database() { Session::flush(); @@ -85,11 +95,19 @@ public function it_polls_for_updates() ->assertSee('License registration in progress') ->assertSeeHtml('wire:poll.2s="loadData"'); - Cache::put('test@example.com.license_key', 'polled-license-key'); + $user = User::factory()->create([ + 'email' => 'test@example.com', + ]); + + License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'db-polled-license-key', + 'policy_name' => 'max', + ]); $component->call('loadData') - ->assertSet('licenseKey', 'polled-license-key') - ->assertSee('polled-license-key') + ->assertSet('licenseKey', 'db-polled-license-key') + ->assertSee('db-polled-license-key') ->assertDontSee('License registration in progress'); }