diff --git a/app/Exceptions/InvalidStateException.php b/app/Exceptions/InvalidStateException.php new file mode 100644 index 00000000..28ec36a0 --- /dev/null +++ b/app/Exceptions/InvalidStateException.php @@ -0,0 +1,10 @@ +invoice->billing_reason) { + Invoice::BILLING_REASON_SUBSCRIPTION_CREATE => $this->createLicense(), + Invoice::BILLING_REASON_SUBSCRIPTION_UPDATE => null, // TODO: Handle subscription update + Invoice::BILLING_REASON_SUBSCRIPTION_CYCLE => null, // TODO: Handle subscription renewal + default => null, + }; + } + + private function createLicense(): void + { + // Assert the invoice line item is for a price_id that relates to a license plan. + $plan = Subscription::fromStripePriceId($this->invoice->lines->first()->price->id); + + // Assert the invoice line item relates to a subscription and has a subscription item id. + if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) { + throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); + } + + // Assert we have a subscription item record for this subscription item id. + $subscriptionItemModel = SubscriptionItem::query()->where('stripe_id', $subscriptionItemId)->firstOrFail(); + + // Assert we don't already have an existing license for this subscription item. + if ($license = License::query()->whereBelongsTo($subscriptionItemModel)->first()) { + throw new InvalidStateException("A license [{$license->id}] already exists for subscription item [{$subscriptionItemModel->id}]."); + } + + $user = $this->billable(); + + dispatch(new CreateAnystackLicenseJob( + $user, + $plan, + $subscriptionItemModel->id, + $user->first_name, + $user->last_name, + )); + } + + private function billable(): User + { + if ($user = Cashier::findBillable($this->invoice->customer)) { + return $user; + } + + $customer = Cashier::stripe()->customers->retrieve($this->invoice->customer); + + dispatch_sync(new CreateUserFromStripeCustomer($customer)); + + return Cashier::findBillable($this->invoice->customer); + } +} diff --git a/app/Listeners/StripeWebhookHandledListener.php b/app/Listeners/StripeWebhookHandledListener.php index d91ac73f..7e1d2255 100644 --- a/app/Listeners/StripeWebhookHandledListener.php +++ b/app/Listeners/StripeWebhookHandledListener.php @@ -2,7 +2,6 @@ namespace App\Listeners; -use App\Jobs\HandleCustomerSubscriptionCreatedJob; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookHandled; @@ -11,10 +10,5 @@ class StripeWebhookHandledListener public function handle(WebhookHandled $event): void { Log::debug('Webhook handled', $event->payload); - - match ($event->payload['type']) { - 'customer.subscription.created' => dispatch(new HandleCustomerSubscriptionCreatedJob($event)), - default => null, - }; } } diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index 4b853c2a..c8048bfe 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -3,10 +3,12 @@ namespace App\Listeners; use App\Jobs\CreateUserFromStripeCustomer; +use App\Jobs\HandleInvoicePaidJob; use Exception; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; +use Stripe\Invoice; class StripeWebhookReceivedListener { @@ -15,6 +17,7 @@ public function handle(WebhookReceived $event): void Log::debug('Webhook received', $event->payload); match ($event->payload['type']) { + 'invoice.paid' => $this->handleInvoicePaid($event), 'customer.subscription.created' => $this->createUserIfNotExists($event->payload['data']['object']['customer']), default => null, }; @@ -36,4 +39,11 @@ private function createUserIfNotExists(string $stripeCustomerId): void dispatch_sync(new CreateUserFromStripeCustomer($customer)); } + + private function handleInvoicePaid(WebhookReceived $event): void + { + $invoice = Invoice::constructFrom($event->payload['data']['object']); + + dispatch(new HandleInvoicePaidJob($invoice)); + } } diff --git a/tests/Feature/StripePurchaseHandlingTest.php b/tests/Feature/StripePurchaseHandlingTest.php index bc486cca..d54b5b03 100644 --- a/tests/Feature/StripePurchaseHandlingTest.php +++ b/tests/Feature/StripePurchaseHandlingTest.php @@ -133,7 +133,7 @@ public function a_user_is_not_created_when_a_stripe_customer_subscription_is_cre } #[Test] - public function a_license_is_created_when_a_stripe_subscription_is_created() + public function a_license_is_not_created_when_a_stripe_subscription_is_created() { Bus::fake([CreateAnystackLicenseJob::class]); @@ -172,6 +172,78 @@ public function a_license_is_created_when_a_stripe_subscription_is_created() $this->postJson('/stripe/webhook', $payload); + Bus::assertNotDispatched(CreateAnystackLicenseJob::class); + + $user->refresh(); + + $this->assertNotEmpty($user->subscriptions); + $this->assertNotEmpty($user->subscriptions->first()->items); + } + + #[Test] + public function a_license_is_created_when_a_stripe_invoice_is_paid() + { + Bus::fake([CreateAnystackLicenseJob::class]); + + $user = User::factory()->create([ + 'stripe_id' => 'cus_test123', + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + \Laravel\Cashier\Subscription::factory() + ->for($user, 'user') + ->create([ + 'stripe_id' => 'sub_test123', + 'stripe_status' => 'incomplete', // the subscription is incomplete at the time this webhook is sent + 'stripe_price' => Subscription::Max->stripePriceId(), + 'quantity' => 1, + ]); + \Laravel\Cashier\SubscriptionItem::factory() + ->for($user->subscriptions->first(), 'subscription') + ->create([ + 'stripe_id' => 'si_test', + 'stripe_price' => Subscription::Max->stripePriceId(), + 'quantity' => 1, + ]); + + $this->mockStripeClient($user); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'invoice.paid', + 'data' => [ + 'object' => [ + 'id' => 'in_test', + 'object' => 'invoice', + 'billing_reason' => 'subscription_create', + 'customer' => 'cus_test123', + 'paid' => true, + 'status' => 'paid', + 'lines' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'il_test', + 'price' => [ + 'id' => Subscription::Max->stripePriceId(), + 'object' => 'price', + 'product' => 'prod_test', + ], + 'quantity' => 1, + 'subscription' => 'sub_test123', + 'subscription_item' => 'si_test', + 'type' => 'subscription', + ], + ], + ], + 'subscription' => 'sub_test123', + ], + ], + ]; + + $this->postJson('/stripe/webhook', $payload); + Bus::assertDispatched(CreateAnystackLicenseJob::class, function (CreateAnystackLicenseJob $job) { return $job->user->email === 'john@example.com' && $job->subscription === Subscription::Max && @@ -179,11 +251,6 @@ public function a_license_is_created_when_a_stripe_subscription_is_created() $job->firstName === 'John' && $job->lastName === 'Doe'; }); - - $user->refresh(); - - $this->assertNotEmpty($user->subscriptions); - $this->assertNotEmpty($user->subscriptions->first()->items); } protected function mockStripeClient(?User $user = null): void