From de82f79f5499e3760e37c8eb3d7f60f26e0d1101 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 15 May 2025 16:27:17 -0400 Subject: [PATCH 1/4] add commands and jobs to import existing anystack data --- .env.example | 3 + .../ImportAnystackContactsCommand.php | 23 ++++++ .../ImportAnystackLicensesCommand.php | 23 ++++++ app/Enums/Subscription.php | 14 ++++ app/Jobs/CreateUserFromStripeCustomer.php | 11 ++- app/Jobs/ImportAnystackContacts.php | 54 +++++++++++++ app/Jobs/ImportAnystackLicenses.php | 57 ++++++++++++++ app/Jobs/ImportAnystackLicensesForUser.php | 29 +++++++ app/Jobs/UpsertLicenseFromAnystackLicense.php | 53 +++++++++++++ app/Jobs/UpsertUserFromAnystackContact.php | 75 +++++++++++++++++++ app/Providers/AppServiceProvider.php | 6 ++ config/subscriptions.php | 14 ++++ ..._05_15_200226_create_job_batches_table.php | 35 +++++++++ 13 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ImportAnystackContactsCommand.php create mode 100644 app/Console/Commands/ImportAnystackLicensesCommand.php create mode 100644 app/Jobs/ImportAnystackContacts.php create mode 100644 app/Jobs/ImportAnystackLicenses.php create mode 100644 app/Jobs/ImportAnystackLicensesForUser.php create mode 100644 app/Jobs/UpsertLicenseFromAnystackLicense.php create mode 100644 app/Jobs/UpsertUserFromAnystackContact.php create mode 100644 database/migrations/2025_05_15_200226_create_job_batches_table.php diff --git a/.env.example b/.env.example index 6b16085e..10b85055 100644 --- a/.env.example +++ b/.env.example @@ -65,12 +65,15 @@ STRIPE_WEBHOOK_SECRET= STRIPE_MINI_PRICE_ID= STRIPE_PRO_PRICE_ID= STRIPE_MAX_PRICE_ID= +STRIPE_FOREVER_PRICE_ID= STRIPE_MINI_PAYMENT_LINK= STRIPE_PRO_PAYMENT_LINK= STRIPE_MAX_PAYMENT_LINK= +STRIPE_FOREVER_PAYMENT_LINK= ANYSTACK_API_KEY= ANYSTACK_PRODUCT_ID= ANYSTACK_MINI_POLICY_ID= ANYSTACK_PRO_POLICY_ID= ANYSTACK_MAX_POLICY_ID= +ANYSTACK_FOREVER_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/ImportAnystackLicensesForUser.php b/app/Jobs/ImportAnystackLicensesForUser.php new file mode 100644 index 00000000..21f146e0 --- /dev/null +++ b/app/Jobs/ImportAnystackLicensesForUser.php @@ -0,0 +1,29 @@ +user->anystack_contact_id) { + $this->fail('Cannot import licenses: user does not have an anystack_contact_id.'); + + return; + } + + } +} 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..c73588fd --- /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'); + } +}; From bf9e55e69929481755f72d02507cf734abe7fcb7 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 15 May 2025 16:29:08 -0400 Subject: [PATCH 2/4] add trial subscription plan to .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 10b85055..7e253e4f 100644 --- a/.env.example +++ b/.env.example @@ -66,10 +66,12 @@ 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= @@ -77,3 +79,4 @@ ANYSTACK_MINI_POLICY_ID= ANYSTACK_PRO_POLICY_ID= ANYSTACK_MAX_POLICY_ID= ANYSTACK_FOREVER_POLICY_ID= +ANYSTACK_TRIAL_POLICY_ID= From 304827cf7e05815ed2608ebe32d21587363fd51d Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 15 May 2025 16:37:41 -0400 Subject: [PATCH 3/4] fix $user->exists() to $user->exists --- app/Jobs/UpsertUserFromAnystackContact.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Jobs/UpsertUserFromAnystackContact.php b/app/Jobs/UpsertUserFromAnystackContact.php index c73588fd..b5a00cdd 100644 --- a/app/Jobs/UpsertUserFromAnystackContact.php +++ b/app/Jobs/UpsertUserFromAnystackContact.php @@ -38,7 +38,7 @@ public function handle(): void $this->assertUserAttributesAreValid($user); - Log::debug(($user->exists() ? "Updating user [{$user->id}]" : 'Creating user')." from anystack contact [{$this->contactData['id']}]."); + 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']; From d3e65c914f432f6871e057224823bc13eb11a0b5 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 15 May 2025 20:30:19 -0400 Subject: [PATCH 4/4] remove unused job --- app/Jobs/ImportAnystackLicensesForUser.php | 29 ---------------------- 1 file changed, 29 deletions(-) delete mode 100644 app/Jobs/ImportAnystackLicensesForUser.php diff --git a/app/Jobs/ImportAnystackLicensesForUser.php b/app/Jobs/ImportAnystackLicensesForUser.php deleted file mode 100644 index 21f146e0..00000000 --- a/app/Jobs/ImportAnystackLicensesForUser.php +++ /dev/null @@ -1,29 +0,0 @@ -user->anystack_contact_id) { - $this->fail('Cannot import licenses: user does not have an anystack_contact_id.'); - - return; - } - - } -}