diff --git a/app/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php index e7e0c0ae..f96437df 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\User; use App\Notifications\LicenseKeyGenerated; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -12,14 +13,13 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Notification; class CreateAnystackLicenseJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( - public string $email, + public User $user, public Subscription $subscription, public ?string $firstName = null, public ?string $lastName = null, @@ -27,18 +27,22 @@ public function __construct( public function handle(): void { - $contact = $this->createContact(); + if (! $this->user->anystack_contact_id) { + $contact = $this->createContact(); - $license = $this->createLicense($contact['id']); + $this->user->anystack_contact_id = $contact['id']; + $this->user->save(); + } - Cache::put($this->email.'.license_key', $license['key'], now()->addDay()); + $license = $this->createLicense($this->user->anystack_contact_id); - Notification::route('mail', $this->email) - ->notify(new LicenseKeyGenerated( - $license['key'], - $this->subscription, - $this->firstName - )); + Cache::put($this->user->email.'.license_key', $license['key'], now()->addDay()); + + $this->user->notify(new LicenseKeyGenerated( + $license['key'], + $this->subscription, + $this->firstName + )); } private function createContact(): array @@ -46,7 +50,7 @@ private function createContact(): array $data = collect([ 'first_name' => $this->firstName, 'last_name' => $this->lastName, - 'email' => $this->email, + 'email' => $this->user->email, ]) ->filter() ->all(); diff --git a/app/Jobs/CreateUserFromStripeCustomer.php b/app/Jobs/CreateUserFromStripeCustomer.php index a54a5057..5b8f6f81 100644 --- a/app/Jobs/CreateUserFromStripeCustomer.php +++ b/app/Jobs/CreateUserFromStripeCustomer.php @@ -10,6 +10,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Laravel\Cashier\Cashier; use Stripe\Customer; @@ -39,6 +40,10 @@ public function handle(): void return; } + Validator::validate(['email' => $this->customer->email], [ + 'email' => 'required|email|max:255', + ]); + $user = new User; $user->name = $this->customer->name; $user->email = $this->customer->email; diff --git a/app/Jobs/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php index 78941ef4..022a1aab 100644 --- a/app/Jobs/HandleCustomerSubscriptionCreatedJob.php +++ b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -27,9 +28,10 @@ public function handle(): void return; } + /** @var User $user */ $user = Cashier::findBillable($stripeSubscription->customer); - if (! $user || ! ($email = $user->email)) { + if (! $user || ! $user->email) { $this->fail('Failed to find user from Stripe subscription customer.'); return; @@ -42,7 +44,7 @@ public function handle(): void $lastName = $nameParts[1] ?? null; dispatch(new CreateAnystackLicenseJob( - $email, + $user, $subscriptionPlan, $firstName, $lastName, diff --git a/database/migrations/2025_05_10_012033_add_anystack_contact_id_to_users_table.php b/database/migrations/2025_05_10_012033_add_anystack_contact_id_to_users_table.php new file mode 100644 index 00000000..c9e2851a --- /dev/null +++ b/database/migrations/2025_05_10_012033_add_anystack_contact_id_to_users_table.php @@ -0,0 +1,34 @@ +string('anystack_contact_id')->nullable()->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex([ + 'anystack_contact_id', + ]); + + $table->dropColumn([ + 'anystack_contact_id', + ]); + }); + } +}; diff --git a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php index 05747d25..a4bb989f 100644 --- a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php +++ b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php @@ -4,6 +4,7 @@ use App\Enums\Subscription; use App\Jobs\CreateAnystackLicenseJob; +use App\Models\User; use App\Notifications\LicenseKeyGenerated; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -45,8 +46,13 @@ protected function setUp(): void /** @test */ public function it_creates_contact_and_license_on_anystack() { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'name' => 'John Doe', + ]); + $job = new CreateAnystackLicenseJob( - 'test@example.com', + $user, Subscription::Max, 'John', 'Doe' @@ -79,8 +85,13 @@ public function it_creates_contact_and_license_on_anystack() /** @test */ public function it_stores_license_key_in_cache() { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'name' => 'John Doe', + ]); + $job = new CreateAnystackLicenseJob( - 'test@example.com', + $user, Subscription::Max, 'John', 'Doe' @@ -94,8 +105,13 @@ public function it_stores_license_key_in_cache() /** @test */ public function it_sends_license_key_notification() { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'name' => 'John Doe', + ]); + $job = new CreateAnystackLicenseJob( - 'test@example.com', + $user, Subscription::Max, 'John', 'Doe' @@ -103,11 +119,10 @@ public function it_sends_license_key_notification() $job->handle(); - Notification::assertSentOnDemand( - LicenseKeyGenerated::class, + Notification::assertSentTo( + $user, function (LicenseKeyGenerated $notification, array $channels, object $notifiable) { - return $notifiable->routes['mail'] === 'test@example.com' && - $notification->licenseKey === 'test-license-key-12345' && + return $notification->licenseKey === 'test-license-key-12345' && $notification->subscription === Subscription::Max && $notification->firstName === 'John'; } @@ -117,9 +132,14 @@ function (LicenseKeyGenerated $notification, array $channels, object $notifiable /** @test */ public function it_handles_missing_name_components() { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'name' => null, + ]); + // Create and run the job with missing name components $job = new CreateAnystackLicenseJob( - 'test@example.com', + $user, Subscription::Max, ); @@ -135,11 +155,10 @@ public function it_handles_missing_name_components() }); // Assert notification was sent with null firstName - Notification::assertSentOnDemand( - LicenseKeyGenerated::class, + Notification::assertSentTo( + $user, function (LicenseKeyGenerated $notification, array $channels, object $notifiable) { - return $notifiable->routes['mail'] === 'test@example.com' && - $notification->licenseKey === 'test-license-key-12345' && + return $notification->licenseKey === 'test-license-key-12345' && $notification->subscription === Subscription::Max && $notification->firstName === null; } diff --git a/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php b/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php index 6bbb3dfd..e2166db2 100644 --- a/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php +++ b/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php @@ -6,6 +6,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Illuminate\Validation\ValidationException; use Stripe\Customer; use Tests\TestCase; @@ -96,4 +97,22 @@ public function it_handles_a_null_name_in_stripe_customer() 'stripe_id' => 'cus_noname123', ]); } + + /** @test */ + public function it_fails_when_customer_has_no_email() + { + $customer = Customer::constructFrom([ + 'id' => 'cus_noemail123', + 'name' => 'No Email', + 'email' => '', + ]); + + $job = new CreateUserFromStripeCustomer($customer); + + $this->expectException(ValidationException::class); + + $job->handle(); + + $this->assertDatabaseCount('users', 0); + } } diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php index 2b1ba3c6..ad1955f2 100644 --- a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -6,6 +6,7 @@ use App\Jobs\CreateAnystackLicenseJob; use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleCustomerSubscriptionCreatedJob; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Laravel\Cashier\Events\WebhookHandled; @@ -38,7 +39,8 @@ public function it_dispatches_the_create_anystack_license_job_with_correct_data( $job->handle(); Bus::assertDispatched(CreateAnystackLicenseJob::class, function (CreateAnystackLicenseJob $job) { - return $job->email === 'test@example.com' && + return $job->user instanceof User && + $job->user->email === 'test@example.com' && $job->subscription === Subscription::Max && $job->firstName === 'John' && $job->lastName === 'Doe'; @@ -99,7 +101,11 @@ public function it_fails_when_customer_has_no_email() $this->mockStripeClient($mockCustomer); - dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + User::factory()->create([ + 'stripe_id' => 'cus_S9dhoV2rJK2Auy', + 'name' => 'John Doe', + 'email' => '', + ]); Bus::fake(); diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php index 5092dd4b..e81c0574 100644 --- a/tests/Feature/Livewire/OrderSuccessTest.php +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -153,6 +153,8 @@ public function allLineItems() } }; - $this->app->instance(StripeClient::class, $mockStripeClient); + $this->app->bind(StripeClient::class, function ($app, $parameters) use ($mockStripeClient) { + return $mockStripeClient; + }); } } diff --git a/tests/Feature/StripePurchaseHandlingTest.php b/tests/Feature/StripePurchaseHandlingTest.php index ca8c18f7..d3954b06 100644 --- a/tests/Feature/StripePurchaseHandlingTest.php +++ b/tests/Feature/StripePurchaseHandlingTest.php @@ -92,8 +92,8 @@ public function a_license_is_created_when_a_stripe_subscription_is_created() $this->postJson('/stripe/webhook', $payload); - Bus::assertDispatched(CreateAnystackLicenseJob::class, function ($job) { - return $job->email === 'john@example.com' && + Bus::assertDispatched(CreateAnystackLicenseJob::class, function (CreateAnystackLicenseJob $job) { + return $job->user->email === 'john@example.com' && $job->subscription === Subscription::Max && $job->firstName === 'John' && $job->lastName === 'Doe';