diff --git a/.env.example b/.env.example index 6b16085e..7e253e4f 100644 --- a/.env.example +++ b/.env.example @@ -65,12 +65,18 @@ STRIPE_WEBHOOK_SECRET= STRIPE_MINI_PRICE_ID= STRIPE_PRO_PRICE_ID= STRIPE_MAX_PRICE_ID= +STRIPE_FOREVER_PRICE_ID= +STRIPE_TRIAL_PRICE_ID= STRIPE_MINI_PAYMENT_LINK= STRIPE_PRO_PAYMENT_LINK= STRIPE_MAX_PAYMENT_LINK= +STRIPE_FOREVER_PAYMENT_LINK= +STRIPE_TRIAL_PAYMENT_LINK= ANYSTACK_API_KEY= ANYSTACK_PRODUCT_ID= ANYSTACK_MINI_POLICY_ID= ANYSTACK_PRO_POLICY_ID= ANYSTACK_MAX_POLICY_ID= +ANYSTACK_FOREVER_POLICY_ID= +ANYSTACK_TRIAL_POLICY_ID= diff --git a/app/Console/Commands/ImportAnystackContactsCommand.php b/app/Console/Commands/ImportAnystackContactsCommand.php new file mode 100644 index 00000000..f6a96e81 --- /dev/null +++ b/app/Console/Commands/ImportAnystackContactsCommand.php @@ -0,0 +1,23 @@ +name('import-anystack-contacts') + ->dispatch(); + } +} diff --git a/app/Console/Commands/ImportAnystackLicensesCommand.php b/app/Console/Commands/ImportAnystackLicensesCommand.php new file mode 100644 index 00000000..5793e6d8 --- /dev/null +++ b/app/Console/Commands/ImportAnystackLicensesCommand.php @@ -0,0 +1,23 @@ +name('import-anystack-licenses') + ->dispatch(); + } +} diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php index 785fddd0..d767f438 100644 --- a/app/Enums/Subscription.php +++ b/app/Enums/Subscription.php @@ -9,6 +9,8 @@ enum Subscription: string case Mini = 'mini'; case Pro = 'pro'; case Max = 'max'; + case Forever = 'forever'; + case Trial = 'trial'; public static function fromStripeSubscription(\Stripe\Subscription $subscription): self { @@ -31,6 +33,18 @@ public static function fromStripePriceId(string $priceId): self }; } + public static function fromAnystackPolicy(string $policyId): self + { + return match ($policyId) { + config('subscriptions.plans.mini.anystack_policy_id') => self::Mini, + config('subscriptions.plans.pro.anystack_policy_id') => self::Pro, + config('subscriptions.plans.max.anystack_policy_id') => self::Max, + config('subscriptions.plans.forever.anystack_policy_id') => self::Forever, + config('subscriptions.plans.trial.anystack_policy_id') => self::Trial, + default => throw new RuntimeException("Unknown Anystack policy id: {$policyId}"), + }; + } + public function name(): string { return config("subscriptions.plans.{$this->value}.name"); diff --git a/app/Jobs/CreateUserFromStripeCustomer.php b/app/Jobs/CreateUserFromStripeCustomer.php index 5b8f6f81..600100e4 100644 --- a/app/Jobs/CreateUserFromStripeCustomer.php +++ b/app/Jobs/CreateUserFromStripeCustomer.php @@ -30,7 +30,9 @@ public function handle(): void return; } - if ($user = User::query()->where('email', $this->customer->email)->first()) { + $user = User::query()->where('email', $this->customer->email)->first(); + + if ($user && filled($user->stripe_id)) { // This could occur if a user performs/attempts multiple checkouts with the same email address. // In the event all existing stripe customers for this email address do NOT have an active // subscription, we could theoretically update the stripe_id for the existing user @@ -40,6 +42,13 @@ public function handle(): void return; } + if ($user) { + $user->stripe_id = $this->customer->id; + $user->save(); + + return; + } + Validator::validate(['email' => $this->customer->email], [ 'email' => 'required|email|max:255', ]); diff --git a/app/Jobs/ImportAnystackContacts.php b/app/Jobs/ImportAnystackContacts.php new file mode 100644 index 00000000..e90cac0e --- /dev/null +++ b/app/Jobs/ImportAnystackContacts.php @@ -0,0 +1,54 @@ +addMinutes(20); + } + + public function handle(): void + { + $response = Http::acceptJson() + ->withToken(config('services.anystack.key')) + ->get("https://api.anystack.sh/v1/contacts?page={$this->page}") + ->json(); + + collect($response['data']) + ->each(function (array $contact) { + dispatch(new UpsertUserFromAnystackContact($contact)); + }); + + if (filled($response['links']['next'])) { + $this->batch()?->add(new self($this->page + 1)); + } + } +} diff --git a/app/Jobs/ImportAnystackLicenses.php b/app/Jobs/ImportAnystackLicenses.php new file mode 100644 index 00000000..a09a2e7e --- /dev/null +++ b/app/Jobs/ImportAnystackLicenses.php @@ -0,0 +1,57 @@ +addMinutes(20); + } + + public function handle(): void + { + $productId = Subscription::Max->anystackProductId(); + + $response = Http::acceptJson() + ->withToken(config('services.anystack.key')) + ->get("https://api.anystack.sh/v1/products/$productId/licenses?page={$this->page}") + ->json(); + + collect($response['data']) + ->each(function (array $license) { + dispatch(new UpsertLicenseFromAnystackLicense($license)); + }); + + if (filled($response['links']['next'])) { + $this->batch()?->add(new self($this->page + 1)); + } + } +} diff --git a/app/Jobs/UpsertLicenseFromAnystackLicense.php b/app/Jobs/UpsertLicenseFromAnystackLicense.php new file mode 100644 index 00000000..7a86e830 --- /dev/null +++ b/app/Jobs/UpsertLicenseFromAnystackLicense.php @@ -0,0 +1,53 @@ + $this->licenseData['key']], $this->values()); + } + + protected function values(): array + { + $values = [ + 'anystack_id' => $this->licenseData['id'], + // subscription_item_id is not set here because we don't want to replace any existing values. + 'policy_name' => Subscription::fromAnystackPolicy($this->licenseData['policy_id'])->value, + 'expires_at' => $this->licenseData['expires_at'], + 'created_at' => $this->licenseData['created_at'], + 'updated_at' => $this->licenseData['updated_at'], + ]; + + if ($user = $this->user()) { + $values['user_id'] = $user->id; + } + + return $values; + } + + protected function user(): User + { + return User::query() + ->where('anystack_contact_id', $this->licenseData['contact_id']) + ->firstOrFail(); + } +} diff --git a/app/Jobs/UpsertUserFromAnystackContact.php b/app/Jobs/UpsertUserFromAnystackContact.php new file mode 100644 index 00000000..b5a00cdd --- /dev/null +++ b/app/Jobs/UpsertUserFromAnystackContact.php @@ -0,0 +1,75 @@ +matchingUsers(); + + if ($users->count() > 1) { + $userIds = $users->pluck('id')->implode(', '); + + throw new Exception("Multiple users [$userIds] found for contact by id [{$this->contactData['id']}] or email [{$this->contactData['email']}]"); + } + + $user = $users->first() ?? new User; + + $this->assertUserAttributesAreValid($user); + + Log::debug(($user->exists ? "Updating user [{$user->id}]" : 'Creating user')." from anystack contact [{$this->contactData['id']}]."); + + $user->anystack_contact_id ??= $this->contactData['id']; + $user->email ??= $this->contactData['email']; + $user->name ??= $this->contactData['full_name']; + $user->created_at ??= $this->contactData['created_at']; + $user->updated_at ??= $this->contactData['updated_at']; + $user->password ??= Hash::make(Str::random(72)); + + $user->save(); + } + + protected function matchingUsers(): Collection + { + return User::query() + ->where('email', $this->contactData['email']) + ->orWhere('anystack_contact_id', $this->contactData['id']) + ->get(); + } + + protected function assertUserAttributesAreValid(User $user): void + { + if (! $user->exists) { + return; + } + + if (filled($user->anystack_contact_id) && $user->anystack_contact_id !== $this->contactData['id']) { + throw new Exception("User [{$user->id}] already exists but the user's anystack_contact_id [{$user->anystack_contact_id}] does not match the id [{$this->contactData['id']}]."); + } + + if ($user->email !== $this->contactData['email']) { + throw new Exception("User [{$user->id}] already exists but the user's email [{$user->email}] does not match the email [{$this->contactData['email']}]."); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 66fb3ecf..8eb8862a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,8 +3,10 @@ namespace App\Providers; use App\Support\GitHub; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Queue\Events\JobFailed; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Sentry\State\Scope; @@ -30,6 +32,10 @@ public function boot(): void $this->registerSharedViewVariables(); $this->sendFailingJobsToSentry(); + + RateLimiter::for('anystack', function () { + return Limit::perMinute(30); + }); } private function registerSharedViewVariables(): void diff --git a/config/subscriptions.php b/config/subscriptions.php index ecb46c8b..ea97182f 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -23,5 +23,19 @@ 'anystack_product_id' => env('ANYSTACK_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_MAX_POLICY_ID'), ], + \App\Enums\Subscription::Forever->value => [ + 'name' => 'Forever', + 'stripe_price_id' => env('STRIPE_FOREVER_PRICE_ID'), + 'stripe_payment_link' => env('STRIPE_FOREVER_PAYMENT_LINK'), + 'anystack_product_id' => env('ANYSTACK_PRODUCT_ID'), + 'anystack_policy_id' => env('ANYSTACK_FOREVER_POLICY_ID'), + ], + \App\Enums\Subscription::Trial->value => [ + 'name' => 'Trial', + 'stripe_price_id' => env('STRIPE_TRIAL_PRICE_ID'), + 'stripe_payment_link' => env('STRIPE_TRIAL_PAYMENT_LINK'), + 'anystack_product_id' => env('ANYSTACK_PRODUCT_ID'), + 'anystack_policy_id' => env('ANYSTACK_TRIAL_POLICY_ID'), + ], ], ]; diff --git a/database/migrations/2025_05_15_200226_create_job_batches_table.php b/database/migrations/2025_05_15_200226_create_job_batches_table.php new file mode 100644 index 00000000..50e38c20 --- /dev/null +++ b/database/migrations/2025_05_15_200226_create_job_batches_table.php @@ -0,0 +1,35 @@ +string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_batches'); + } +};