Skip to content

Commit 0993d92

Browse files
committed
Revert "Revert "New billing flow (#121)""
This reverts commit e571ffc.
1 parent e571ffc commit 0993d92

36 files changed

+2754
-191
lines changed

.env.example

+16
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,19 @@ VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
5858
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
5959

6060
TORCHLIGHT_TOKEN=
61+
62+
STRIPE_KEY=
63+
STRIPE_SECRET=
64+
STRIPE_WEBHOOK_SECRET=
65+
STRIPE_MINI_PRICE_ID=
66+
STRIPE_PRO_PRICE_ID=
67+
STRIPE_MAX_PRICE_ID=
68+
STRIPE_MINI_PAYMENT_LINK=
69+
STRIPE_PRO_PAYMENT_LINK=
70+
STRIPE_MAX_PAYMENT_LINK=
71+
72+
ANYSTACK_API_KEY=
73+
ANYSTACK_PRODUCT_ID=
74+
ANYSTACK_MINI_POLICY_ID=
75+
ANYSTACK_PRO_POLICY_ID=
76+
ANYSTACK_MAX_POLICY_ID=

app/Enums/Subscription.php

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
use RuntimeException;
6+
7+
enum Subscription: string
8+
{
9+
case Mini = 'mini';
10+
case Pro = 'pro';
11+
case Max = 'max';
12+
13+
public static function fromStripeSubscription(\Stripe\Subscription $subscription): self
14+
{
15+
$priceId = $subscription->items->first()?->price->id;
16+
17+
if (! $priceId) {
18+
throw new RuntimeException('Could not resolve Stripe price id from subscription object.');
19+
}
20+
21+
return self::fromStripePriceId($priceId);
22+
}
23+
24+
public static function fromStripePriceId(string $priceId): self
25+
{
26+
return match ($priceId) {
27+
config('subscriptions.plans.mini.stripe_price_id') => self::Mini,
28+
config('subscriptions.plans.pro.stripe_price_id') => self::Pro,
29+
config('subscriptions.plans.max.stripe_price_id') => self::Max,
30+
default => throw new RuntimeException("Unknown Stripe price id: {$priceId}"),
31+
};
32+
}
33+
34+
public function name(): string
35+
{
36+
return config("subscriptions.plans.{$this->value}.name");
37+
}
38+
39+
public function stripePriceId(): string
40+
{
41+
return config("subscriptions.plans.{$this->value}.stripe_price_id");
42+
}
43+
44+
public function stripePaymentLink(): string
45+
{
46+
return config("subscriptions.plans.{$this->value}.stripe_payment_link");
47+
}
48+
49+
public function anystackProductId(): string
50+
{
51+
return config("subscriptions.plans.{$this->value}.anystack_product_id");
52+
}
53+
54+
public function anystackPolicyId(): string
55+
{
56+
return config("subscriptions.plans.{$this->value}.anystack_policy_id");
57+
}
58+
}

app/Http/Middleware/VerifyCsrfToken.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
1212
* @var array<int, string>
1313
*/
1414
protected $except = [
15-
//
15+
'stripe/webhook',
1616
];
1717
}

app/Jobs/CreateAnystackLicenseJob.php

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Enums\Subscription;
6+
use App\Notifications\LicenseKeyGenerated;
7+
use Illuminate\Bus\Queueable;
8+
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Foundation\Bus\Dispatchable;
10+
use Illuminate\Http\Client\PendingRequest;
11+
use Illuminate\Queue\InteractsWithQueue;
12+
use Illuminate\Queue\SerializesModels;
13+
use Illuminate\Support\Facades\Cache;
14+
use Illuminate\Support\Facades\Http;
15+
use Illuminate\Support\Facades\Notification;
16+
17+
class CreateAnystackLicenseJob implements ShouldQueue
18+
{
19+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
20+
21+
public function __construct(
22+
public string $email,
23+
public Subscription $subscription,
24+
public ?string $firstName = null,
25+
public ?string $lastName = null,
26+
) {}
27+
28+
public function handle(): void
29+
{
30+
$contact = $this->createContact();
31+
32+
$license = $this->createLicense($contact['id']);
33+
34+
Cache::put($this->email.'.license_key', $license['key'], now()->addDay());
35+
36+
Notification::route('mail', $this->email)
37+
->notify(new LicenseKeyGenerated(
38+
$license['key'],
39+
$this->subscription,
40+
$this->firstName
41+
));
42+
}
43+
44+
private function createContact(): array
45+
{
46+
$data = collect([
47+
'first_name' => $this->firstName,
48+
'last_name' => $this->lastName,
49+
'email' => $this->email,
50+
])
51+
->filter()
52+
->all();
53+
54+
// TODO: If an existing contact with the same email address already exists,
55+
// anystack will return a 422 validation error response.
56+
return $this->anystackClient()
57+
->post('https://api.anystack.sh/v1/contacts', $data)
58+
->throw()
59+
->json('data');
60+
}
61+
62+
private function createLicense(string $contactId): ?array
63+
{
64+
$data = [
65+
'policy_id' => $this->subscription->anystackPolicyId(),
66+
'contact_id' => $contactId,
67+
];
68+
69+
return $this->anystackClient()
70+
->post("https://api.anystack.sh/v1/products/{$this->subscription->anystackProductId()}/licenses", $data)
71+
->throw()
72+
->json('data');
73+
}
74+
75+
private function anystackClient(): PendingRequest
76+
{
77+
return Http::withToken(config('services.anystack.key'))
78+
->acceptJson()
79+
->asJson();
80+
}
81+
}
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Models\User;
6+
use Illuminate\Bus\Queueable;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Foundation\Bus\Dispatchable;
9+
use Illuminate\Queue\InteractsWithQueue;
10+
use Illuminate\Queue\SerializesModels;
11+
use Illuminate\Support\Facades\Hash;
12+
use Illuminate\Support\Str;
13+
use Laravel\Cashier\Cashier;
14+
use Stripe\Customer;
15+
16+
class CreateUserFromStripeCustomer implements ShouldQueue
17+
{
18+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
19+
20+
public function __construct(public Customer $customer) {}
21+
22+
public function handle(): void
23+
{
24+
if (Cashier::findBillable($this->customer)) {
25+
$this->fail("A user already exists for Stripe customer [{$this->customer->id}].");
26+
27+
return;
28+
}
29+
30+
if (User::query()->where('email', $this->customer->email)->exists()) {
31+
$this->fail("A user already exists for email [{$this->customer->email}].");
32+
33+
return;
34+
}
35+
36+
$user = new User;
37+
$user->name = $this->customer->name;
38+
$user->email = $this->customer->email;
39+
$user->stripe_id = $this->customer->id;
40+
// We will create a random password for the user and expect them to reset it.
41+
$user->password = Hash::make(Str::random(72));
42+
43+
$user->save();
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use Illuminate\Bus\Queueable;
6+
use Illuminate\Contracts\Queue\ShouldQueue;
7+
use Illuminate\Foundation\Bus\Dispatchable;
8+
use Illuminate\Queue\InteractsWithQueue;
9+
use Illuminate\Queue\SerializesModels;
10+
use Laravel\Cashier\Cashier;
11+
use Laravel\Cashier\Events\WebhookHandled;
12+
use Stripe\Subscription;
13+
14+
class HandleCustomerSubscriptionCreatedJob implements ShouldQueue
15+
{
16+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
17+
18+
public function __construct(public WebhookHandled $webhook) {}
19+
20+
public function handle(): void
21+
{
22+
$stripeSubscription = $this->constructStripeSubscription();
23+
24+
if (! $stripeSubscription) {
25+
$this->fail('The Stripe webhook payload could not be constructed into a Stripe Subscription object.');
26+
27+
return;
28+
}
29+
30+
$user = Cashier::findBillable($stripeSubscription->customer);
31+
32+
if (! $user || ! ($email = $user->email)) {
33+
$this->fail('Failed to find user from Stripe subscription customer.');
34+
35+
return;
36+
}
37+
38+
$subscriptionPlan = \App\Enums\Subscription::fromStripeSubscription($stripeSubscription);
39+
40+
$nameParts = explode(' ', $user->name ?? '', 2);
41+
$firstName = $nameParts[0] ?: null;
42+
$lastName = $nameParts[1] ?? null;
43+
44+
dispatch(new CreateAnystackLicenseJob(
45+
$email,
46+
$subscriptionPlan,
47+
$firstName,
48+
$lastName,
49+
));
50+
}
51+
52+
protected function constructStripeSubscription(): ?Subscription
53+
{
54+
return Subscription::constructFrom($this->webhook->payload['data']['object']);
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace App\Listeners;
4+
5+
use App\Jobs\HandleCustomerSubscriptionCreatedJob;
6+
use Illuminate\Support\Facades\Log;
7+
use Laravel\Cashier\Events\WebhookHandled;
8+
9+
class StripeWebhookHandledListener
10+
{
11+
public function handle(WebhookHandled $event): void
12+
{
13+
Log::debug('Webhook handled', $event->payload);
14+
15+
match ($event->payload['type']) {
16+
'customer.subscription.created' => dispatch(new HandleCustomerSubscriptionCreatedJob($event)),
17+
default => null,
18+
};
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace App\Listeners;
4+
5+
use App\Jobs\CreateUserFromStripeCustomer;
6+
use Illuminate\Support\Facades\Log;
7+
use Laravel\Cashier\Events\WebhookReceived;
8+
use Stripe\Customer;
9+
10+
class StripeWebhookReceivedListener
11+
{
12+
public function handle(WebhookReceived $event): void
13+
{
14+
Log::debug('Webhook received', $event->payload);
15+
16+
match ($event->payload['type']) {
17+
// 'customer.created' must be dispatched sync so the user is
18+
// created before the cashier webhook handling is executed.
19+
'customer.created' => dispatch_sync(new CreateUserFromStripeCustomer(
20+
Customer::constructFrom($event->payload['data']['object'])
21+
)),
22+
default => null,
23+
};
24+
}
25+
}

0 commit comments

Comments
 (0)