Skip to content

Commit 594e5ac

Browse files
authored
Create a license upon invoice.paid event (#149)
* create a license upon invoice.paid event * set $maxExceptions property on HandleInvoicePaidJob
1 parent 8e70ac1 commit 594e5ac

File tree

5 files changed

+171
-12
lines changed

5 files changed

+171
-12
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace App\Exceptions;
4+
5+
use Exception;
6+
7+
class InvalidStateException extends Exception
8+
{
9+
//
10+
}

app/Jobs/HandleInvoicePaidJob.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Enums\Subscription;
6+
use App\Exceptions\InvalidStateException;
7+
use App\Models\License;
8+
use App\Models\User;
9+
use Illuminate\Bus\Queueable;
10+
use Illuminate\Contracts\Queue\ShouldQueue;
11+
use Illuminate\Foundation\Bus\Dispatchable;
12+
use Illuminate\Queue\InteractsWithQueue;
13+
use Illuminate\Queue\SerializesModels;
14+
use Laravel\Cashier\Cashier;
15+
use Laravel\Cashier\SubscriptionItem;
16+
use Stripe\Invoice;
17+
use UnexpectedValueException;
18+
19+
class HandleInvoicePaidJob implements ShouldQueue
20+
{
21+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
22+
23+
public int $maxExceptions = 1;
24+
25+
public function __construct(public Invoice $invoice) {}
26+
27+
public function handle(): void
28+
{
29+
match ($this->invoice->billing_reason) {
30+
Invoice::BILLING_REASON_SUBSCRIPTION_CREATE => $this->createLicense(),
31+
Invoice::BILLING_REASON_SUBSCRIPTION_UPDATE => null, // TODO: Handle subscription update
32+
Invoice::BILLING_REASON_SUBSCRIPTION_CYCLE => null, // TODO: Handle subscription renewal
33+
default => null,
34+
};
35+
}
36+
37+
private function createLicense(): void
38+
{
39+
// Assert the invoice line item is for a price_id that relates to a license plan.
40+
$plan = Subscription::fromStripePriceId($this->invoice->lines->first()->price->id);
41+
42+
// Assert the invoice line item relates to a subscription and has a subscription item id.
43+
if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) {
44+
throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.');
45+
}
46+
47+
// Assert we have a subscription item record for this subscription item id.
48+
$subscriptionItemModel = SubscriptionItem::query()->where('stripe_id', $subscriptionItemId)->firstOrFail();
49+
50+
// Assert we don't already have an existing license for this subscription item.
51+
if ($license = License::query()->whereBelongsTo($subscriptionItemModel)->first()) {
52+
throw new InvalidStateException("A license [{$license->id}] already exists for subscription item [{$subscriptionItemModel->id}].");
53+
}
54+
55+
$user = $this->billable();
56+
57+
dispatch(new CreateAnystackLicenseJob(
58+
$user,
59+
$plan,
60+
$subscriptionItemModel->id,
61+
$user->first_name,
62+
$user->last_name,
63+
));
64+
}
65+
66+
private function billable(): User
67+
{
68+
if ($user = Cashier::findBillable($this->invoice->customer)) {
69+
return $user;
70+
}
71+
72+
$customer = Cashier::stripe()->customers->retrieve($this->invoice->customer);
73+
74+
dispatch_sync(new CreateUserFromStripeCustomer($customer));
75+
76+
return Cashier::findBillable($this->invoice->customer);
77+
}
78+
}

app/Listeners/StripeWebhookHandledListener.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace App\Listeners;
44

5-
use App\Jobs\HandleCustomerSubscriptionCreatedJob;
65
use Illuminate\Support\Facades\Log;
76
use Laravel\Cashier\Events\WebhookHandled;
87

@@ -11,10 +10,5 @@ class StripeWebhookHandledListener
1110
public function handle(WebhookHandled $event): void
1211
{
1312
Log::debug('Webhook handled', $event->payload);
14-
15-
match ($event->payload['type']) {
16-
'customer.subscription.created' => dispatch(new HandleCustomerSubscriptionCreatedJob($event)),
17-
default => null,
18-
};
1913
}
2014
}

app/Listeners/StripeWebhookReceivedListener.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace App\Listeners;
44

55
use App\Jobs\CreateUserFromStripeCustomer;
6+
use App\Jobs\HandleInvoicePaidJob;
67
use Exception;
78
use Illuminate\Support\Facades\Log;
89
use Laravel\Cashier\Cashier;
910
use Laravel\Cashier\Events\WebhookReceived;
11+
use Stripe\Invoice;
1012

1113
class StripeWebhookReceivedListener
1214
{
@@ -15,6 +17,7 @@ public function handle(WebhookReceived $event): void
1517
Log::debug('Webhook received', $event->payload);
1618

1719
match ($event->payload['type']) {
20+
'invoice.paid' => $this->handleInvoicePaid($event),
1821
'customer.subscription.created' => $this->createUserIfNotExists($event->payload['data']['object']['customer']),
1922
default => null,
2023
};
@@ -36,4 +39,11 @@ private function createUserIfNotExists(string $stripeCustomerId): void
3639

3740
dispatch_sync(new CreateUserFromStripeCustomer($customer));
3841
}
42+
43+
private function handleInvoicePaid(WebhookReceived $event): void
44+
{
45+
$invoice = Invoice::constructFrom($event->payload['data']['object']);
46+
47+
dispatch(new HandleInvoicePaidJob($invoice));
48+
}
3949
}

tests/Feature/StripePurchaseHandlingTest.php

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ public function a_user_is_not_created_when_a_stripe_customer_subscription_is_cre
133133
}
134134

135135
#[Test]
136-
public function a_license_is_created_when_a_stripe_subscription_is_created()
136+
public function a_license_is_not_created_when_a_stripe_subscription_is_created()
137137
{
138138
Bus::fake([CreateAnystackLicenseJob::class]);
139139

@@ -172,18 +172,85 @@ public function a_license_is_created_when_a_stripe_subscription_is_created()
172172

173173
$this->postJson('/stripe/webhook', $payload);
174174

175+
Bus::assertNotDispatched(CreateAnystackLicenseJob::class);
176+
177+
$user->refresh();
178+
179+
$this->assertNotEmpty($user->subscriptions);
180+
$this->assertNotEmpty($user->subscriptions->first()->items);
181+
}
182+
183+
#[Test]
184+
public function a_license_is_created_when_a_stripe_invoice_is_paid()
185+
{
186+
Bus::fake([CreateAnystackLicenseJob::class]);
187+
188+
$user = User::factory()->create([
189+
'stripe_id' => 'cus_test123',
190+
'name' => 'John Doe',
191+
'email' => '[email protected]',
192+
]);
193+
194+
\Laravel\Cashier\Subscription::factory()
195+
->for($user, 'user')
196+
->create([
197+
'stripe_id' => 'sub_test123',
198+
'stripe_status' => 'incomplete', // the subscription is incomplete at the time this webhook is sent
199+
'stripe_price' => Subscription::Max->stripePriceId(),
200+
'quantity' => 1,
201+
]);
202+
\Laravel\Cashier\SubscriptionItem::factory()
203+
->for($user->subscriptions->first(), 'subscription')
204+
->create([
205+
'stripe_id' => 'si_test',
206+
'stripe_price' => Subscription::Max->stripePriceId(),
207+
'quantity' => 1,
208+
]);
209+
210+
$this->mockStripeClient($user);
211+
212+
$payload = [
213+
'id' => 'evt_test_webhook',
214+
'type' => 'invoice.paid',
215+
'data' => [
216+
'object' => [
217+
'id' => 'in_test',
218+
'object' => 'invoice',
219+
'billing_reason' => 'subscription_create',
220+
'customer' => 'cus_test123',
221+
'paid' => true,
222+
'status' => 'paid',
223+
'lines' => [
224+
'object' => 'list',
225+
'data' => [
226+
[
227+
'id' => 'il_test',
228+
'price' => [
229+
'id' => Subscription::Max->stripePriceId(),
230+
'object' => 'price',
231+
'product' => 'prod_test',
232+
],
233+
'quantity' => 1,
234+
'subscription' => 'sub_test123',
235+
'subscription_item' => 'si_test',
236+
'type' => 'subscription',
237+
],
238+
],
239+
],
240+
'subscription' => 'sub_test123',
241+
],
242+
],
243+
];
244+
245+
$this->postJson('/stripe/webhook', $payload);
246+
175247
Bus::assertDispatched(CreateAnystackLicenseJob::class, function (CreateAnystackLicenseJob $job) {
176248
return $job->user->email === '[email protected]' &&
177249
$job->subscription === Subscription::Max &&
178250
$job->subscriptionItemId === $job->user->subscriptions->first()->items()->first()->id &&
179251
$job->firstName === 'John' &&
180252
$job->lastName === 'Doe';
181253
});
182-
183-
$user->refresh();
184-
185-
$this->assertNotEmpty($user->subscriptions);
186-
$this->assertNotEmpty($user->subscriptions->first()->items);
187254
}
188255

189256
protected function mockStripeClient(?User $user = null): void

0 commit comments

Comments
 (0)