From ac25b35f629101e747ee31bff65c0b12a431733a Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:11:43 -0400 Subject: [PATCH 01/26] add stripe packages and install --- .env.example | 18 ++ app/Http/Middleware/VerifyCsrfToken.php | 2 +- app/Providers/AppServiceProvider.php | 5 +- composer.json | 2 + composer.lock | 264 +++++++++++++++++- config/services.php | 11 + config/stripe-webhooks.php | 50 ++++ ...4_17_185913_create_webhook_calls_table.php | 23 ++ routes/web.php | 3 + 9 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 config/stripe-webhooks.php create mode 100644 database/migrations/2025_04_17_185913_create_webhook_calls_table.php diff --git a/.env.example b/.env.example index 6886b6d1..0d200e49 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,21 @@ VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" TORCHLIGHT_TOKEN= + +STRIPE_KEY= +STRIPE_SECRET= +STRIPE_WEBHOOK_SECRET= +STRIPE_MINI_PRICE_ID= +STRIPE_PRO_PRICE_ID= +STRIPE_MAX_PRICE_ID= +STRIPE_MINI_PAYMENT_LINK= +STRIPE_PRO_PAYMENT_LINK= +STRIPE_MAX_PAYMENT_LINK= + +ANYSTACK_API_KEY= +ANYSTACK_MINI_PRODUCT_ID= +ANYSTACK_PRO_PRODUCT_ID= +ANYSTACK_MAX_PRODUCT_ID= +ANYSTACK_MINI_POLICY_ID= +ANYSTACK_PRO_POLICY_ID= +ANYSTACK_MAX_POLICY_ID= diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 9e865217..a067f42e 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + 'stripe/webhook', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f58138ee..b0f12aae 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ use App\Support\GitHub; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; +use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider { @@ -13,7 +14,9 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(StripeClient::class, function () { + return new StripeClient(config('services.stripe.secret')); + }); } /** diff --git a/composer.json b/composer.json index 821ca1f8..46f0f817 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,9 @@ "laravel/tinker": "^2.8", "league/commonmark": "^2.4", "spatie/laravel-menu": "^4.1", + "spatie/laravel-stripe-webhooks": "^3.10", "spatie/yaml-front-matter": "^2.0", + "stripe/stripe-php": "^17.1", "torchlight/torchlight-commonmark": "^0.5.5" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 7c84117b..3317f9c0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d122544fddb1584d07dc227cdfb97593", + "content-hash": "af724de8fa17d6da3f1d0287ece12c9f", "packages": [ { "name": "artesaos/seotools", @@ -3462,6 +3462,209 @@ ], "time": "2025-02-21T10:35:09+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.92.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d20b1969f836d210459b78683d85c9cd5c5f508c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d20b1969f836d210459b78683d85c9cd5c5f508c", + "reference": "d20b1969f836d210459b78683d85c9cd5c5f508c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.5.24|^10.5|^11.5", + "spatie/pest-plugin-test-time": "^1.1|^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.4" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-04-11T15:27:14+00:00" + }, + { + "name": "spatie/laravel-stripe-webhooks", + "version": "3.10.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-stripe-webhooks.git", + "reference": "fe1d939b292c50dc4674b93e9be6127f2ee91aa7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-stripe-webhooks/zipball/fe1d939b292c50dc4674b93e9be6127f2ee91aa7", + "reference": "fe1d939b292c50dc4674b93e9be6127f2ee91aa7", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0", + "spatie/laravel-webhook-client": "^3.0", + "stripe/stripe-php": "^7.51|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0|^14.0|^15.0|^16.0|^17.0" + }, + "require-dev": { + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.4|^10.5|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\StripeWebhooks\\StripeWebhooksServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\StripeWebhooks\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Handle stripe webhooks in a Laravel application", + "homepage": "https://github.com/spatie/laravel-stripe-webhooks", + "keywords": [ + "laravel-stripe-webhooks", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-stripe-webhooks/issues", + "source": "https://github.com/spatie/laravel-stripe-webhooks/tree/3.10.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2025-04-04T17:58:20+00:00" + }, + { + "name": "spatie/laravel-webhook-client", + "version": "3.4.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-webhook-client.git", + "reference": "c7148e271a52b85da666bc7e3e1112b56138568b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-webhook-client/zipball/c7148e271a52b85da666bc7e3e1112b56138568b", + "reference": "c7148e271a52b85da666bc7e3e1112b56138568b", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/database": "^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0", + "php": "^8.1 || ^8.2", + "spatie/laravel-package-tools": "^1.11" + }, + "require-dev": { + "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.3 || ^10.5 || ^11.5.3 || ^12.0", + "spatie/laravel-ray": "^1.24" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\WebhookClient\\WebhookClientServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Spatie\\WebhookClient\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Receive webhooks in Laravel apps", + "homepage": "https://github.com/spatie/laravel-webhook-client", + "keywords": [ + "laravel-webhook-client", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-webhook-client/issues", + "source": "https://github.com/spatie/laravel-webhook-client/tree/3.4.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2025-02-14T13:01:09+00:00" + }, { "name": "spatie/macroable", "version": "2.0.0", @@ -3701,6 +3904,65 @@ ], "time": "2024-12-02T08:40:45+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v17.1.1", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "01ca9b5fdd899b8e4b69f83b85e09d96f6240220" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/01ca9b5fdd899b8e4b69f83b85e09d96f6240220", + "reference": "01ca9b5fdd899b8e4b69f83b85e09d96f6240220", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v17.1.1" + }, + "time": "2025-04-05T00:09:14+00:00" + }, { "name": "symfony/console", "version": "v6.4.20", diff --git a/config/services.php b/config/services.php index 0ace530e..98124ac9 100644 --- a/config/services.php +++ b/config/services.php @@ -31,4 +31,15 @@ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ], + + 'anystack' => [ + 'key' => env('ANYSTACK_API_KEY'), + ], ]; diff --git a/config/stripe-webhooks.php b/config/stripe-webhooks.php new file mode 100644 index 00000000..6540941a --- /dev/null +++ b/config/stripe-webhooks.php @@ -0,0 +1,50 @@ + env('STRIPE_WEBHOOK_SECRET'), + + /* + * You can define a default job that should be run for all other Stripe event type + * without a job defined in next configuration. + * You may leave it empty to store the job in database but without processing it. + */ + 'default_job' => '', + + /* + * You can define the job that should be run when a certain webhook hits your application + * here. The key is the name of the Stripe event type with the `.` replaced by a `_`. + * + * You can find a list of Stripe webhook types here: + * https://stripe.com/docs/api#event_types. + */ + 'jobs' => [ + 'customer_subscription_created' => \App\Jobs\StripeWebhooks\HandleCustomerSubscriptionCreatedJob::class, + ], + + /* + * The classname of the model to be used. The class should equal or extend + * Spatie\WebhookClient\Models\WebhookCall. + */ + 'model' => \Spatie\WebhookClient\Models\WebhookCall::class, + + /** + * This class determines if the webhook call should be stored and processed. + */ + 'profile' => \Spatie\StripeWebhooks\StripeWebhookProfile::class, + + /* + * Specify a connection and or a queue to process the webhooks + */ + 'connection' => env('STRIPE_WEBHOOK_CONNECTION'), + 'queue' => env('STRIPE_WEBHOOK_QUEUE'), + + /* + * When disabled, the package will not verify if the signature is valid. + * This can be handy in local environments. + */ + 'verify_signature' => env('STRIPE_SIGNATURE_VERIFY', true), +]; diff --git a/database/migrations/2025_04_17_185913_create_webhook_calls_table.php b/database/migrations/2025_04_17_185913_create_webhook_calls_table.php new file mode 100644 index 00000000..5f8769fe --- /dev/null +++ b/database/migrations/2025_04_17_185913_create_webhook_calls_table.php @@ -0,0 +1,23 @@ +bigIncrements('id'); + + $table->string('name'); + $table->string('url'); + $table->json('headers')->nullable(); + $table->json('payload')->nullable(); + $table->text('exception')->nullable(); + + $table->timestamps(); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index a45a03c8..313c7cc0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -50,3 +50,6 @@ return redirect("/docs/{$version}/{$page}"); })->name('docs')->where('page', '.*'); + +Route::get('/order/{checkoutSessionId}', App\Http\Controllers\OrderSuccessController::class)->name('order.success'); +Route::stripeWebhooks('stripe/webhook'); From ff0bd519619cdd2c7aa470d2e421bd508c30f3fc Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:12:51 -0400 Subject: [PATCH 02/26] add subscription.created webhook handling --- app/Jobs/CreateAnystackLicenseJob.php | 72 +++++++++++++++++++ .../HandleCustomerSubscriptionCreatedJob.php | 65 +++++++++++++++++ app/Support/Stripe/StripePrice.php | 55 ++++++++++++++ config/subscriptions.php | 27 +++++++ .../views/components/mobile-pricing.blade.php | 6 +- 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 app/Jobs/CreateAnystackLicenseJob.php create mode 100644 app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php create mode 100644 app/Support/Stripe/StripePrice.php create mode 100644 config/subscriptions.php diff --git a/app/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php new file mode 100644 index 00000000..a0bf6823 --- /dev/null +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -0,0 +1,72 @@ +createContact(); + + $license = $this->createLicense($contact['id']); + + Cache::put($this->email.'.license_key', $license['key'], now()->addDay()); + } + + private function createContact(): array + { + $data = collect([ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'email' => $this->email, + ]) + ->filter() + ->all(); + + // TODO: It's unknown what will happen if an existing contact with + // the same email address already exists. + return $this->anystackClient() + ->post('https://api.anystack.sh/v1/contacts', $data) + ->throw() + ->json('data'); + } + + private function createLicense(string $contactId): ?array + { + $data = [ + 'policy_id' => $this->policyId, + 'contact_id' => $contactId, + ]; + + return $this->anystackClient() + ->post("https://api.anystack.sh/v1/products/{$this->productId}/licenses", $data) + ->throw() + ->json('data'); + } + + private function anystackClient(): PendingRequest + { + return Http::withToken(config('services.anystack.key')) + ->acceptJson() + ->asJson(); + } +} diff --git a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php new file mode 100644 index 00000000..01327a1d --- /dev/null +++ b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php @@ -0,0 +1,65 @@ +constructStripeSubscription(); + + if (! $stripeSubscription) { + $this->fail('The Stripe webhook payload could not be constructed into a Stripe Subscription object.'); + + return; + } + + $customer = app(StripeClient::class) + ->customers + ->retrieve($stripeSubscription->customer); + + if (! $customer || ! ($email = $customer->email)) { + $this->fail('Failed to retrieve customer information or customer has no email.'); + + return; + } + + $price = $stripeSubscription->items->first()?->price; + + $productId = StripePrice::from($price)->getAnystackProductId(); + $policyId = StripePrice::from($price)->getAnystackPolicyId(); + + $nameParts = explode(' ', $customer->name ?? '', 2); + $firstName = $nameParts[0] ?? null; + $lastName = $nameParts[1] ?? null; + + dispatch(new CreateAnystackLicenseJob( + $email, + $productId, + $policyId, + $firstName, + $lastName, + )); + } + + protected function constructStripeSubscription(): ?Subscription + { + return Event::constructFrom($this->webhook->payload)->data?->object; + } +} diff --git a/app/Support/Stripe/StripePrice.php b/app/Support/Stripe/StripePrice.php new file mode 100644 index 00000000..6a839d47 --- /dev/null +++ b/app/Support/Stripe/StripePrice.php @@ -0,0 +1,55 @@ +getPlan()['anystack_product_id']; + } + + public function getAnystackPolicyId(): string + { + return $this->getPlan()['anystack_policy_id']; + } + + public function getPlanName(): string + { + return $this->getPlan()['name']; + } + + public function getPlan(): array + { + return $this->plan ?? $this->resolvePlan(); + } + + protected function resolvePlan(): array + { + $plan = Arr::first( + config('subscriptions.plans'), + fn ($value, $key): bool => $value['stripe_price_id'] === $this->price->id + ); + + if (! $plan) { + throw new Exception("Could not resolve plan for Stripe price_id: {$this->price->id}"); + } + + return $plan; + } +} diff --git a/config/subscriptions.php b/config/subscriptions.php new file mode 100644 index 00000000..cfb26c80 --- /dev/null +++ b/config/subscriptions.php @@ -0,0 +1,27 @@ + [ + 'mini' => [ + 'name' => 'Early Access (Mini)', + 'stripe_price_id' => env('STRIPE_MINI_PRICE_ID'), + 'stripe_payment_link' => env('STRIPE_MINI_PAYMENT_LINK'), + 'anystack_product_id' => env('ANYSTACK_MINI_PRODUCT_ID'), + 'anystack_policy_id' => env('ANYSTACK_MINI_POLICY_ID'), + ], + 'pro' => [ + 'name' => 'Early Access (Pro)', + 'stripe_price_id' => env('STRIPE_PRO_PRICE_ID'), + 'stripe_payment_link' => env('STRIPE_PRO_PAYMENT_LINK'), + 'anystack_product_id' => env('ANYSTACK_PRO_PRODUCT_ID'), + 'anystack_policy_id' => env('ANYSTACK_PRO_POLICY_ID'), + ], + 'max' => [ + 'name' => 'Early Access (Max)', + 'stripe_price_id' => env('STRIPE_MAX_PRICE_ID'), + 'stripe_payment_link' => env('STRIPE_MAX_PAYMENT_LINK'), + 'anystack_product_id' => env('ANYSTACK_MAX_PRODUCT_ID'), + 'anystack_policy_id' => env('ANYSTACK_MAX_POLICY_ID'), + ], + ], +]; diff --git a/resources/views/components/mobile-pricing.blade.php b/resources/views/components/mobile-pricing.blade.php index 861bafd3..1716500e 100644 --- a/resources/views/components/mobile-pricing.blade.php +++ b/resources/views/components/mobile-pricing.blade.php @@ -120,7 +120,7 @@ class="size-5 shrink-0" {{-- Button --}} @@ -283,7 +283,7 @@ class="size-5 shrink-0" {{-- Button --}} @@ -454,7 +454,7 @@ class="size-5 shrink-0" {{-- Button --}} From 24985ac1ab90af47ff196cd2dcd3a23744f9e039 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:13:01 -0400 Subject: [PATCH 03/26] add order success page --- .../Controllers/OrderSuccessController.php | 38 ++ resources/views/order-success.blade.php | 446 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 app/Http/Controllers/OrderSuccessController.php create mode 100644 resources/views/order-success.blade.php diff --git a/app/Http/Controllers/OrderSuccessController.php b/app/Http/Controllers/OrderSuccessController.php new file mode 100644 index 00000000..f2ab2a06 --- /dev/null +++ b/app/Http/Controllers/OrderSuccessController.php @@ -0,0 +1,38 @@ +isLocal()) { + return view('order-success', [ + 'email' => $checkoutSessionId === 'withkey' ? 'user@example.com' : null, + 'licenseKey' => $checkoutSessionId === 'withkey' ? '3715203a-8305-4fab-8ff6-e52a31c409ab' : null, + ]); + } + + $stripe = app(StripeClient::class); + $checkoutSession = $stripe->checkout->sessions->retrieve($checkoutSessionId); + + if (! $checkoutSession) { + return to_route('early-adopter'); + } + + $request->session()->put('customer_email', $email = $checkoutSession->customer_details->email); + + if ($licenseKey = Cache::get($email.'.license_key')) { + $request->session()->put('license_key', $licenseKey); + } + + return view('order-success', [ + 'email' => $email, + 'licenseKey' => $licenseKey, + ]); + } +} diff --git a/resources/views/order-success.blade.php b/resources/views/order-success.blade.php new file mode 100644 index 00000000..3507e89a --- /dev/null +++ b/resources/views/order-success.blade.php @@ -0,0 +1,446 @@ + + {{-- Hero Section --}} +
+
+ {{-- Primary Heading --}} +

+ You're In! +

+ + {{-- Introduction Description --}} +

+ We're excited to have you join the NativePHP community! +

+
+ + {{-- Success Card --}} +
+ + {{-- Next Steps --}} + +
+ From 9396484599b97928974ab4fea5a4e87dbe0b1e37 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:29:50 -0400 Subject: [PATCH 04/26] fix nullable name components in CreateAnystackLicenseJob --- app/Jobs/CreateAnystackLicenseJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php index a0bf6823..9be6e563 100644 --- a/app/Jobs/CreateAnystackLicenseJob.php +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -19,8 +19,8 @@ public function __construct( public string $email, public string $productId, public string $policyId, - public string $firstName, - public string $lastName, + public ?string $firstName = null, + public ?string $lastName = null, ) {} public function handle(): void From 9041c476f6aa63e06b7e43a8a8db31d04ba43d0e Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:30:13 -0400 Subject: [PATCH 05/26] fix margin of "view installation guide" button --- resources/views/order-success.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/order-success.blade.php b/resources/views/order-success.blade.php index 3507e89a..55efc4af 100644 --- a/resources/views/order-success.blade.php +++ b/resources/views/order-success.blade.php @@ -264,7 +264,7 @@ class="mt-2 text-center text-gray-600 dark:text-gray-400"
Date: Fri, 18 Apr 2025 11:30:34 -0400 Subject: [PATCH 06/26] remove local testing code --- app/Http/Controllers/OrderSuccessController.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/Http/Controllers/OrderSuccessController.php b/app/Http/Controllers/OrderSuccessController.php index f2ab2a06..550f4464 100644 --- a/app/Http/Controllers/OrderSuccessController.php +++ b/app/Http/Controllers/OrderSuccessController.php @@ -10,13 +10,6 @@ class OrderSuccessController extends Controller { public function __invoke(Request $request, string $checkoutSessionId) { - if (app()->isLocal()) { - return view('order-success', [ - 'email' => $checkoutSessionId === 'withkey' ? 'user@example.com' : null, - 'licenseKey' => $checkoutSessionId === 'withkey' ? '3715203a-8305-4fab-8ff6-e52a31c409ab' : null, - ]); - } - $stripe = app(StripeClient::class); $checkoutSession = $stripe->checkout->sessions->retrieve($checkoutSessionId); From 7cb732bd675da2b092b7d636cd85af6e8e61da2d Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 12:05:48 -0400 Subject: [PATCH 07/26] send license key email notification upon creation --- app/Jobs/CreateAnystackLicenseJob.php | 8 +++ app/Notifications/LicenseKeyGenerated.php | 60 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 app/Notifications/LicenseKeyGenerated.php diff --git a/app/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php index 9be6e563..f56223fd 100644 --- a/app/Jobs/CreateAnystackLicenseJob.php +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Notifications\LicenseKeyGenerated; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -10,6 +11,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Notification; class CreateAnystackLicenseJob implements ShouldQueue { @@ -30,6 +32,12 @@ public function handle(): void $license = $this->createLicense($contact['id']); Cache::put($this->email.'.license_key', $license['key'], now()->addDay()); + + Notification::route('mail', $this->email) + ->notify(new LicenseKeyGenerated( + $license['key'], + $this->firstName + )); } private function createContact(): array diff --git a/app/Notifications/LicenseKeyGenerated.php b/app/Notifications/LicenseKeyGenerated.php new file mode 100644 index 00000000..ef122cbb --- /dev/null +++ b/app/Notifications/LicenseKeyGenerated.php @@ -0,0 +1,60 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $greeting = $this->firstName + ? "{$this->firstName}, your license is ready!" + : 'Your license is ready!'; + + return (new MailMessage) + ->subject('Your NativePHP License Key') + ->greeting($greeting) + ->line('Thank you for purchasing a license for the early access program of mobile NativePHP.') + ->line('Your license key is:') + ->line("**{$this->licenseKey}**") + ->line('When prompted by Composer, use your email address as the username and this license key as the password.') + ->action('View Installation Guide', url('/docs/mobile/1/getting-started/installation')) + ->line("If you have any questions, please don't hesitate to reach out to our support team.") + ->salutation("Happy coding!\n\nThe NativePHP Team") + ->success(); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'license_key' => $this->licenseKey, + 'firstName' => $this->firstName, + ]; + } +} From e973c95268b1747f705b9191c605e1ebe0aa3a29 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Fri, 18 Apr 2025 12:09:18 -0400 Subject: [PATCH 08/26] pinting --- app/Support/Stripe/StripePrice.php | 4 +--- .../2025_04_17_185913_create_webhook_calls_table.php | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/Support/Stripe/StripePrice.php b/app/Support/Stripe/StripePrice.php index 6a839d47..87e08be6 100644 --- a/app/Support/Stripe/StripePrice.php +++ b/app/Support/Stripe/StripePrice.php @@ -10,9 +10,7 @@ final class StripePrice { public array $plan; - public function __construct(public readonly Price $price) - { - } + public function __construct(public readonly Price $price) {} public static function from(Price $price): self { diff --git a/database/migrations/2025_04_17_185913_create_webhook_calls_table.php b/database/migrations/2025_04_17_185913_create_webhook_calls_table.php index 5f8769fe..db97aceb 100644 --- a/database/migrations/2025_04_17_185913_create_webhook_calls_table.php +++ b/database/migrations/2025_04_17_185913_create_webhook_calls_table.php @@ -1,7 +1,7 @@ Date: Fri, 18 Apr 2025 12:09:41 -0400 Subject: [PATCH 09/26] prettier --- resources/views/order-success.blade.php | 38 ++++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/resources/views/order-success.blade.php b/resources/views/order-success.blade.php index 55efc4af..5378adaf 100644 --- a/resources/views/order-success.blade.php +++ b/resources/views/order-success.blade.php @@ -74,20 +74,44 @@ class="mx-auto mt-10 max-w-xl" class="overflow-hidden rounded-xl bg-white p-6 shadow-lg dark:bg-mirage" >
- -
{{ $email }}
+ >{{ $email }}
@endif @else @@ -274,7 +274,8 @@ class="mt-6 text-center text-gray-600 dark:text-gray-400" check your email - shortly for a copy of your license key. + shortly for a copy of your license key. You can also + try refreshing this page after a moment.

Date: Sat, 19 Apr 2025 14:43:37 -0400 Subject: [PATCH 12/26] add /mobile route test for stripe payment links --- tests/Feature/MobileRouteTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/Feature/MobileRouteTest.php diff --git a/tests/Feature/MobileRouteTest.php b/tests/Feature/MobileRouteTest.php new file mode 100644 index 00000000..95c85437 --- /dev/null +++ b/tests/Feature/MobileRouteTest.php @@ -0,0 +1,28 @@ + ['stripe_payment_link' => 'https://buy.stripe.com/mini-payment'], + 'pro' => ['stripe_payment_link' => 'https://buy.stripe.com/pro-payment'], + 'max' => ['stripe_payment_link' => 'https://buy.stripe.com/max-payment'], + ]; + + Config::set('subscriptions.plans', $mockLinks); + + $response = $this->withoutVite()->get(route('early-adopter'))->getContent(); + + $this->assertStringContainsString($mockLinks['mini']['stripe_payment_link'], $response); + $this->assertStringContainsString($mockLinks['pro']['stripe_payment_link'], $response); + $this->assertStringContainsString($mockLinks['max']['stripe_payment_link'], $response); + } +} From 845e9879a9edbd2b86add52516d2ab89d0528648 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 19 Apr 2025 14:55:22 -0400 Subject: [PATCH 13/26] add StripeWebhookRouteTest --- .../Feature/Stripe/StripeWebhookRouteTest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/Feature/Stripe/StripeWebhookRouteTest.php diff --git a/tests/Feature/Stripe/StripeWebhookRouteTest.php b/tests/Feature/Stripe/StripeWebhookRouteTest.php new file mode 100644 index 00000000..f2a37ebf --- /dev/null +++ b/tests/Feature/Stripe/StripeWebhookRouteTest.php @@ -0,0 +1,28 @@ +post('/stripe/webhook'); + + $this->assertNotEquals(404, $response->getStatusCode()); + } + + #[Test] + public function stripe_webhook_route_is_excluded_from_csrf_verification() + { + $reflection = new \ReflectionClass(VerifyCsrfToken::class); + $property = $reflection->getProperty('except'); + $exceptPaths = $property->getValue(app(VerifyCsrfToken::class)); + + $this->assertContains('stripe/webhook', $exceptPaths); + } +} From bd3485834ea2aea8cf5240edbca191d59baa3340 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 19 Apr 2025 14:56:27 -0400 Subject: [PATCH 14/26] add StripeWebhookConfigurationTest --- .../Stripe/StripeWebhookConfigurationTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/Feature/Stripe/StripeWebhookConfigurationTest.php diff --git a/tests/Feature/Stripe/StripeWebhookConfigurationTest.php b/tests/Feature/Stripe/StripeWebhookConfigurationTest.php new file mode 100644 index 00000000..5cc647b7 --- /dev/null +++ b/tests/Feature/Stripe/StripeWebhookConfigurationTest.php @@ -0,0 +1,21 @@ +assertArrayHasKey('customer_subscription_created', $stripeWebhookConfig); + $this->assertEquals( + HandleCustomerSubscriptionCreatedJob::class, + $stripeWebhookConfig['customer_subscription_created'] + ); + } +} From 1123958fd0635cc3b963c5c30f66ff439d33e3a4 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:24:49 -0400 Subject: [PATCH 15/26] add HandleCustomerSubscriptionCreatedJobTest --- .../HandleCustomerSubscriptionCreatedJob.php | 2 +- ...ndleCustomerSubscriptionCreatedJobTest.php | 341 ++++++++++++++++++ 2 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php diff --git a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php index 01327a1d..bbe1d8e4 100644 --- a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php +++ b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php @@ -46,7 +46,7 @@ public function handle(): void $policyId = StripePrice::from($price)->getAnystackPolicyId(); $nameParts = explode(' ', $customer->name ?? '', 2); - $firstName = $nameParts[0] ?? null; + $firstName = $nameParts[0] ?: null; $lastName = $nameParts[1] ?? null; dispatch(new CreateAnystackLicenseJob( diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php new file mode 100644 index 00000000..81e5b450 --- /dev/null +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -0,0 +1,341 @@ + [ + 'name' => 'Test Plan', + 'stripe_price_id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'anystack_product_id' => 'test-product-id', + 'anystack_policy_id' => 'test-policy-id', + ], + ]); + } + + /** @test */ + public function it_dispatches_the_create_anystack_license_job_with_correct_data() + { + Bus::fake(); + + // Mock the Stripe client and customer response + $mockCustomer = Customer::constructFrom([ + 'email' => 'test@example.com', + 'name' => 'John Doe', + ]); + + $this->mockStripeClient($mockCustomer); + + // Create a webhook call with the test payload + $webhookCall = WebhookCall::make()->forceFill([ + 'name' => 'stripe', + 'url' => 'https://example.com/webhook', + 'headers' => ['Stripe-Signature' => 'test'], + 'payload' => $this->getTestWebhookPayload(), + ]); + + // Run the job + $job = new HandleCustomerSubscriptionCreatedJob($webhookCall); + $job->handle(); + + // Assert that the CreateAnystackLicenseJob was dispatched with the correct parameters + Bus::assertDispatched(CreateAnystackLicenseJob::class, function ($job) { + return $job->email === 'test@example.com' && + $job->productId === 'test-product-id' && + $job->policyId === 'test-policy-id' && + $job->firstName === 'John' && + $job->lastName === 'Doe'; + }); + } + + /** + * @dataProvider customerNameProvider + * + * @test + */ + public function it_extracts_customer_name_parts_correctly($fullName, $expectedFirstName, $expectedLastName) + { + Bus::fake(); + + $mockCustomer = Customer::constructFrom([ + 'email' => 'test@example.com', + 'name' => $fullName, + ]); + + $this->mockStripeClient($mockCustomer); + + $webhookCall = WebhookCall::make()->forceFill([ + 'name' => 'stripe', + 'url' => 'https://example.com/webhook', + 'headers' => ['Stripe-Signature' => 'test'], + 'payload' => $this->getTestWebhookPayload(), + ]); + + $job = new HandleCustomerSubscriptionCreatedJob($webhookCall); + $job->handle(); + + Bus::assertDispatched(CreateAnystackLicenseJob::class, function ($job) use ($expectedFirstName, $expectedLastName) { + return $job->firstName === $expectedFirstName && + $job->lastName === $expectedLastName; + }); + } + + /** + * Data provider for customer name tests + */ + public function customerNameProvider() + { + return [ + 'Full name' => ['John Doe', 'John', 'Doe'], + 'First name only' => ['Jane', 'Jane', null], + 'Empty string' => ['', null, null], + 'Null value' => [null, null, null], + ]; + } + + /** @test */ + public function it_fails_when_customer_has_no_email() + { + Bus::fake(); + + $mockCustomer = Customer::constructFrom([ + 'email' => null, + 'name' => 'John Doe', + ]); + + $this->mockStripeClient($mockCustomer); + + $webhookCall = WebhookCall::make()->forceFill([ + 'name' => 'stripe', + 'url' => 'https://example.com/webhook', + 'headers' => ['Stripe-Signature' => 'test'], + 'payload' => $this->getTestWebhookPayload(), + ]); + + $job = new HandleCustomerSubscriptionCreatedJob($webhookCall); + $job->handle(); + + Bus::assertNotDispatched(CreateAnystackLicenseJob::class); + } + + protected function getTestWebhookPayload(): array + { + return [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'id' => 'sub_1RFKQDAyFo6rlwXq6Wuu642C', + 'object' => 'subscription', + 'application' => null, + 'application_fee_percent' => null, + 'automatic_tax' => [ + 'disabled_reason' => null, + 'enabled' => false, + 'liability' => null, + ], + 'billing_cycle_anchor' => 1745003875, + 'billing_cycle_anchor_config' => null, + 'billing_thresholds' => null, + 'cancel_at' => null, + 'cancel_at_period_end' => false, + 'canceled_at' => null, + 'cancellation_details' => [ + 'comment' => null, + 'feedback' => null, + 'reason' => null, + ], + 'collection_method' => 'charge_automatically', + 'created' => 1745003875, + 'currency' => 'usd', + 'current_period_end' => 1776539875, + 'current_period_start' => 1745003875, + 'customer' => 'cus_S9dhoV2rJK2Auy', + 'days_until_due' => null, + 'default_payment_method' => 'pm_1RFKQBAyFo6rlwXq0zprYwdm', + 'default_source' => null, + 'default_tax_rates' => [], + 'description' => null, + 'discount' => null, + 'discounts' => [], + 'ended_at' => null, + 'invoice_settings' => [ + 'account_tax_ids' => null, + 'issuer' => [ + 'type' => 'self', + ], + ], + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_S9dhjbP3rnMPYq', + 'object' => 'subscription_item', + 'billing_thresholds' => null, + 'created' => 1745003876, + 'current_period_end' => 1776539875, + 'current_period_start' => 1745003875, + 'discounts' => [], + 'metadata' => [], + 'plan' => [ + 'id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'object' => 'plan', + 'active' => true, + 'aggregate_usage' => null, + 'amount' => 25000, + 'amount_decimal' => '25000', + 'billing_scheme' => 'per_unit', + 'created' => 1744986706, + 'currency' => 'usd', + 'interval' => 'year', + 'interval_count' => 1, + 'livemode' => false, + 'metadata' => [], + 'meter' => null, + 'nickname' => null, + 'product' => 'prod_S9Z5CgycbP7P4y', + 'tiers_mode' => null, + 'transform_usage' => null, + 'trial_period_days' => null, + 'usage_type' => 'licensed', + ], + 'price' => [ + 'id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'object' => 'price', + 'active' => true, + 'billing_scheme' => 'per_unit', + 'created' => 1744986706, + 'currency' => 'usd', + 'custom_unit_amount' => null, + 'livemode' => false, + 'lookup_key' => null, + 'metadata' => [], + 'nickname' => null, + 'product' => 'prod_S9Z5CgycbP7P4y', + 'recurring' => [ + 'aggregate_usage' => null, + 'interval' => 'year', + 'interval_count' => 1, + 'meter' => null, + 'trial_period_days' => null, + 'usage_type' => 'licensed', + ], + 'tax_behavior' => 'unspecified', + 'tiers_mode' => null, + 'transform_quantity' => null, + 'type' => 'recurring', + 'unit_amount' => 25000, + 'unit_amount_decimal' => '25000', + ], + 'quantity' => 1, + 'subscription' => 'sub_1RFKQDAyFo6rlwXq6Wuu642C', + 'tax_rates' => [], + ], + ], + 'has_more' => false, + 'total_count' => 1, + 'url' => '/v1/subscription_items?subscription=sub_1RFKQDAyFo6rlwXq6Wuu642C', + ], + 'latest_invoice' => 'in_1RFKQEAyFo6rlwXqBa5IhGhF', + 'livemode' => false, + 'metadata' => [], + 'next_pending_invoice_item_invoice' => null, + 'on_behalf_of' => null, + 'pause_collection' => null, + 'payment_settings' => [ + 'payment_method_options' => [ + 'acss_debit' => null, + 'bancontact' => null, + 'card' => [ + 'network' => null, + 'request_three_d_secure' => 'automatic', + ], + 'customer_balance' => null, + 'konbini' => null, + 'sepa_debit' => null, + 'us_bank_account' => null, + ], + 'payment_method_types' => null, + 'save_default_payment_method' => 'off', + ], + 'pending_invoice_item_interval' => null, + 'pending_setup_intent' => null, + 'pending_update' => null, + 'plan' => [ + 'id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'object' => 'plan', + 'active' => true, + 'aggregate_usage' => null, + 'amount' => 25000, + 'amount_decimal' => '25000', + 'billing_scheme' => 'per_unit', + 'created' => 1744986706, + 'currency' => 'usd', + 'interval' => 'year', + 'interval_count' => 1, + 'livemode' => false, + 'metadata' => [], + 'meter' => null, + 'nickname' => null, + 'product' => 'prod_S9Z5CgycbP7P4y', + 'tiers_mode' => null, + 'transform_usage' => null, + 'trial_period_days' => null, + 'usage_type' => 'licensed', + ], + 'quantity' => 1, + 'schedule' => null, + 'start_date' => 1745003875, + 'status' => 'active', + 'test_clock' => null, + 'transfer_data' => null, + 'trial_end' => null, + 'trial_settings' => [ + 'end_behavior' => [ + 'missing_payment_method' => 'create_invoice', + ], + ], + 'trial_start' => null, + ], + ], + ]; + } + + protected function mockStripeClient(Customer $mockCustomer): void + { + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->customers = new class($mockCustomer) + { + private $mockCustomer; + + public function __construct($mockCustomer) + { + $this->mockCustomer = $mockCustomer; + } + + public function retrieve() + { + return $this->mockCustomer; + } + }; + + $this->app->instance(StripeClient::class, $mockStripeClient); + } +} From 18441c4ffc161ba2f43b4f976faa1e8c2117b10e Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:32:47 -0400 Subject: [PATCH 16/26] add CreateAnystackLicenseJobTest --- .../Jobs/CreateAnystackLicenseJobTest.php | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/Feature/Jobs/CreateAnystackLicenseJobTest.php diff --git a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php new file mode 100644 index 00000000..32e5725c --- /dev/null +++ b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php @@ -0,0 +1,147 @@ + Http::response([ + 'data' => [ + 'id' => 'contact-123', + 'email' => 'test@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + ], + ], 201), + + 'https://api.anystack.sh/v1/products/*/licenses' => Http::response([ + 'data' => [ + 'id' => 'license-123', + 'key' => 'test-license-key-12345', + 'contact_id' => 'contact-123', + 'policy_id' => 'policy-123', + ], + ], 201), + ]); + + Notification::fake(); + } + + /** @test */ + public function it_creates_contact_and_license_on_anystack() + { + $job = new CreateAnystackLicenseJob( + 'test@example.com', + 'product-123', + 'policy-123', + 'John', + 'Doe' + ); + + $job->handle(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.anystack.sh/v1/contacts' && + $request->method() === 'POST' && + $request->data() === [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'test@example.com', + ]; + }); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.anystack.sh/v1/products/product-123/licenses' && + $request->method() === 'POST' && + $request->data() === [ + 'policy_id' => 'policy-123', + 'contact_id' => 'contact-123', + ]; + }); + } + + /** @test */ + public function it_stores_license_key_in_cache() + { + $job = new CreateAnystackLicenseJob( + 'test@example.com', + 'product-123', + 'policy-123', + 'John', + 'Doe' + ); + + $job->handle(); + + $this->assertEquals('test-license-key-12345', Cache::get('test@example.com.license_key')); + } + + /** @test */ + public function it_sends_license_key_notification() + { + $job = new CreateAnystackLicenseJob( + 'test@example.com', + 'product-123', + 'policy-123', + 'John', + 'Doe' + ); + + $job->handle(); + + Notification::assertSentOnDemand( + LicenseKeyGenerated::class, + function (LicenseKeyGenerated $notification, array $channels, object $notifiable) { + return $notifiable->routes['mail'] === 'test@example.com' && + $notification->licenseKey === 'test-license-key-12345' && + $notification->firstName === 'John'; + } + ); + } + + /** @test */ + public function it_handles_missing_name_components() + { + // Create and run the job with missing name components + $job = new CreateAnystackLicenseJob( + 'test@example.com', + 'product-123', + 'policy-123' + ); + + $job->handle(); + + // Assert HTTP request was made with correct data (no name components) + Http::assertSent(function ($request) { + return $request->url() === 'https://api.anystack.sh/v1/contacts' && + $request->method() === 'POST' && + $request->data() === [ + 'email' => 'test@example.com', + ]; + }); + + // Assert notification was sent with null firstName + Notification::assertSentOnDemand( + LicenseKeyGenerated::class, + function (LicenseKeyGenerated $notification, array $channels, object $notifiable) { + return $notifiable->routes['mail'] === 'test@example.com' && + $notification->licenseKey === 'test-license-key-12345' && + $notification->firstName === null; + } + ); + } +} From f8df37524ecbbd38b302f6606bab8334f46467ce Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:35:04 -0400 Subject: [PATCH 17/26] remove ExampleTest --- tests/Feature/ExampleTest.php | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 tests/Feature/ExampleTest.php diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84e..00000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} From 0c8b6a88aee3ecd1541408f157b6c192241257ba Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:35:20 -0400 Subject: [PATCH 18/26] make dataprovider method static --- tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php index 81e5b450..42a565e2 100644 --- a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -101,7 +101,7 @@ public function it_extracts_customer_name_parts_correctly($fullName, $expectedFi /** * Data provider for customer name tests */ - public function customerNameProvider() + public static function customerNameProvider() { return [ 'Full name' => ['John Doe', 'John', 'Doe'], From 1a22699d8cd58280b8b2c18891636caecaaf25c5 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:43:17 -0400 Subject: [PATCH 19/26] install livewire/livewire and reconfigure alpine --- composer.json | 1 + composer.lock | 78 ++++++++++++++++++++- resources/js/app.js | 16 ++--- resources/views/components/layout.blade.php | 2 + 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 46f0f817..269a702e 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/sanctum": "^3.2", "laravel/tinker": "^2.8", "league/commonmark": "^2.4", + "livewire/livewire": "^3.6", "spatie/laravel-menu": "^4.1", "spatie/laravel-stripe-webhooks": "^3.10", "spatie/yaml-front-matter": "^2.0", diff --git a/composer.lock b/composer.lock index 3317f9c0..20d8d481 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "af724de8fa17d6da3f1d0287ece12c9f", + "content-hash": "808f9210731b7db556da0cbf1b41bca7", "packages": [ { "name": "artesaos/seotools", @@ -2110,6 +2110,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "livewire/livewire", + "version": "v3.6.3", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "56aa1bb63a46e06181c56fa64717a7287e19115e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/56aa1bb63a46e06181c56fa64717a7287e19115e", + "reference": "56aa1bb63a46e06181c56fa64717a7287e19115e", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0", + "symfony/http-kernel": "^6.2|^7.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v3.6.3" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2025-04-12T22:26:52+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", diff --git a/resources/js/app.js b/resources/js/app.js index 3f275f91..788f2a88 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,12 +1,13 @@ import './fonts' import './bootstrap' -import Alpine from 'alpinejs' -import collapse from '@alpinejs/collapse' -import resize from '@alpinejs/resize' -import persist from '@alpinejs/persist' +import { + Livewire, + Alpine, +} from '../../vendor/livewire/livewire/dist/livewire.esm' import codeBlock from './alpine/codeBlock.js' import docsearch from '@docsearch/js' import '@docsearch/css' + import.meta.glob(['../images/**', '../svg/**']) import { animate, @@ -48,8 +49,6 @@ window.motion = { } // Alpine -window.Alpine = Alpine - Alpine.data('codeBlock', codeBlock) Alpine.magic('refAll', (el) => { return (refName) => { @@ -57,10 +56,7 @@ Alpine.magic('refAll', (el) => { } }) -Alpine.plugin(collapse) -Alpine.plugin(persist) -Alpine.plugin(resize) -Alpine.start() +Livewire.start() // Docsearch docsearch({ diff --git a/resources/views/components/layout.blade.php b/resources/views/components/layout.blade.php index 7ef78dfe..9d43d946 100644 --- a/resources/views/components/layout.blade.php +++ b/resources/views/components/layout.blade.php @@ -54,6 +54,7 @@ display: none !important; } + @livewireStyles @vite('resources/css/app.css') {{ $slot }} + @livewireScriptConfig @vite('resources/js/app.js') @vite('resources/css/docsearch.css') From 3f4a6e31bd0d7b22e50349d047edfb662f3041ad Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:43:50 -0400 Subject: [PATCH 20/26] change order success to polling livewire component page --- .../Controllers/OrderSuccessController.php | 57 -------------- app/Livewire/OrderSuccess.php | 68 +++++++++++++++++ .../{ => livewire}/order-success.blade.php | 75 +++++++------------ routes/web.php | 2 +- 4 files changed, 96 insertions(+), 106 deletions(-) delete mode 100644 app/Http/Controllers/OrderSuccessController.php create mode 100644 app/Livewire/OrderSuccess.php rename resources/views/{ => livewire}/order-success.blade.php (85%) diff --git a/app/Http/Controllers/OrderSuccessController.php b/app/Http/Controllers/OrderSuccessController.php deleted file mode 100644 index acbbe841..00000000 --- a/app/Http/Controllers/OrderSuccessController.php +++ /dev/null @@ -1,57 +0,0 @@ -getEmail($request, $checkoutSessionId); - - $licenseKey = $this->getLicenseKey($request, $email); - - return view('order-success', [ - 'email' => $email, - 'licenseKey' => $licenseKey, - ]); - } - - private function getEmail(Request $request, string $checkoutSessionId): ?string - { - if ($email = $request->session()->get('customer_email')) { - return $email; - } - - $stripe = app(StripeClient::class); - $checkoutSession = $stripe->checkout->sessions->retrieve($checkoutSessionId); - - if (! ($email = $checkoutSession?->customer_details?->email)) { - return null; - } - - $request->session()->put('customer_email', $email); - - return $email; - } - - private function getLicenseKey(Request $request, ?string $email): ?string - { - if ($licenseKey = $request->session()->get('license_key')) { - return $licenseKey; - } - - if (! $email) { - return null; - } - - if ($licenseKey = Cache::get($email.'.license_key')) { - $request->session()->put('license_key', $licenseKey); - } - - return $licenseKey; - } -} diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php new file mode 100644 index 00000000..6126b54f --- /dev/null +++ b/app/Livewire/OrderSuccess.php @@ -0,0 +1,68 @@ +checkoutSessionId = $checkoutSessionId; + + $this->loadData(); + } + + public function loadData(): void + { + $this->email = $this->loadEmail(); + $this->licenseKey = $this->loadLicenseKey(); + } + + private function loadEmail(): ?string + { + if ($email = session('customer_email')) { + return $email; + } + + $stripe = app(StripeClient::class); + $checkoutSession = $stripe->checkout->sessions->retrieve($this->checkoutSessionId); + + if (! ($email = $checkoutSession?->customer_details?->email)) { + return null; + } + + session()->put('customer_email', $email); + + return $email; + } + + private function loadLicenseKey(): ?string + { + if ($licenseKey = session('license_key')) { + return $licenseKey; + } + + if (! $this->email) { + return null; + } + + if ($licenseKey = Cache::get($this->email.'.license_key')) { + session()->put('license_key', $licenseKey); + } + + return $licenseKey; + } +} diff --git a/resources/views/order-success.blade.php b/resources/views/livewire/order-success.blade.php similarity index 85% rename from resources/views/order-success.blade.php rename to resources/views/livewire/order-success.blade.php index e7dfb975..a0c36bc1 100644 --- a/resources/views/order-success.blade.php +++ b/resources/views/livewire/order-success.blade.php @@ -1,4 +1,4 @@ - +

{{-- Hero Section --}}
@@ -138,25 +138,18 @@ class="relative mt-2 rounded-md bg-gray-200 p-4 dark:bg-gray-800" }" > +
@@ -381,8 +360,8 @@ class="size-6 text-violet-600 dark:text-violet-400"

Install the Package

- When prompted by Composer, use your email address as the - username and your license key as the password. + Follow our step-by-step guide to install and set up + NativePHP in your Laravel project.

@@ -417,8 +396,8 @@ class="size-6 text-indigo-600 dark:text-indigo-400"

Join Our Community

- Connect with other developers in our Discord community - to share ideas and get help. + Connect with other developers, get help, and share your + experiences in our Discord community.

@@ -468,4 +447,4 @@ class="mb-4 flex h-12 w-12 items-center justify-center rounded-full" - + diff --git a/routes/web.php b/routes/web.php index 313c7cc0..13cae0ac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -51,5 +51,5 @@ return redirect("/docs/{$version}/{$page}"); })->name('docs')->where('page', '.*'); -Route::get('/order/{checkoutSessionId}', App\Http\Controllers\OrderSuccessController::class)->name('order.success'); +Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); Route::stripeWebhooks('stripe/webhook'); From d14321f26261dd1be9ed71ef2c9d0433a7d437a9 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:44:01 -0400 Subject: [PATCH 21/26] add OrderSuccessTest --- tests/Feature/Livewire/OrderSuccessTest.php | 127 ++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/Feature/Livewire/OrderSuccessTest.php diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php new file mode 100644 index 00000000..82131cdb --- /dev/null +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -0,0 +1,127 @@ +mockStripeClient(); + } + + #[Test] + public function it_renders_successfully() + { + $response = $this->withoutVite()->get('/order/cs_test_123'); + + $response->assertStatus(200); + } + + #[Test] + public function it_displays_loading_state_when_no_license_key_is_available() + { + Session::flush(); + + Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) + ->assertSet('email', 'test@example.com') + ->assertSet('licenseKey', null) + ->assertSee('License registration in progress') + ->assertSee('check your email'); + } + + #[Test] + public function it_displays_license_key_when_available() + { + Session::flush(); + + Cache::put('test@example.com.license_key', 'test-license-key-12345'); + + Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) + ->assertSet('email', 'test@example.com') + ->assertSet('licenseKey', 'test-license-key-12345') + ->assertSee('test-license-key-12345') + ->assertSee('test@example.com') + ->assertDontSee('License registration in progress'); + } + + #[Test] + public function it_uses_session_data_when_available() + { + Session::put('customer_email', 'session@example.com'); + Session::put('license_key', 'session-license-key'); + + Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) + ->assertSet('email', 'session@example.com') + ->assertSet('licenseKey', 'session-license-key') + ->assertSee('session-license-key') + ->assertSee('session@example.com'); + } + + #[Test] + public function it_polls_for_updates() + { + Session::flush(); + + $component = Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) + ->assertSet('licenseKey', null) + ->assertSee('License registration in progress') + ->assertSeeHtml('wire:poll.2s="loadData"'); + + Cache::put('test@example.com.license_key', 'polled-license-key'); + + $component->call('loadData') + ->assertSet('licenseKey', 'polled-license-key') + ->assertSee('polled-license-key') + ->assertDontSee('License registration in progress'); + } + + private function mockStripeClient(): void + { + $mockCheckoutSession = CheckoutSession::constructFrom([ + 'id' => 'cs_test_123', + 'customer_details' => [ + 'email' => 'test@example.com', + ], + ]); + + $mockStripeClient = $this->createMock(StripeClient::class); + + $mockStripeClient->checkout = new class($mockCheckoutSession) + { + private $mockCheckoutSession; + + public function __construct($mockCheckoutSession) + { + $this->mockCheckoutSession = $mockCheckoutSession; + } + }; + + $mockStripeClient->checkout->sessions = new class($mockCheckoutSession) + { + private $mockCheckoutSession; + + public function __construct($mockCheckoutSession) + { + $this->mockCheckoutSession = $mockCheckoutSession; + } + + public function retrieve() + { + return $this->mockCheckoutSession; + } + }; + + $this->app->instance(StripeClient::class, $mockStripeClient); + } +} From f469a4aa4327d2923b7423d0d8801fe60a224de4 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:36:44 -0400 Subject: [PATCH 22/26] small fixes in order-success.blade.php --- .../views/livewire/order-success.blade.php | 71 ++++++++++++------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/resources/views/livewire/order-success.blade.php b/resources/views/livewire/order-success.blade.php index a0c36bc1..5263c2ab 100644 --- a/resources/views/livewire/order-success.blade.php +++ b/resources/views/livewire/order-success.blade.php @@ -1,4 +1,5 @@
+ {{-- TODO: CHeck changes from old order-success for wording and html structure --}} {{-- Hero Section --}}
@@ -138,18 +139,25 @@ class="relative mt-2 rounded-md bg-gray-200 p-4 dark:bg-gray-800" }" > -
+
-

@@ -260,8 +272,15 @@ class="mt-6 text-center text-gray-600 dark:text-gray-400" check your email - shortly for a copy of your license key. You can also - try refreshing this page after a moment. + shortly for a copy of your license key. This page + will also update if your license key is ready. +

+ +

+ Once you receive your license key, you can start + building amazing mobile apps with NativePHP!

@endif
From bd8543c19e453d6d258e5b0f0e258becbc3e21a6 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:49:24 -0400 Subject: [PATCH 23/26] only poll when we don't have a license key --- resources/views/livewire/order-success.blade.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/views/livewire/order-success.blade.php b/resources/views/livewire/order-success.blade.php index 5263c2ab..95454a45 100644 --- a/resources/views/livewire/order-success.blade.php +++ b/resources/views/livewire/order-success.blade.php @@ -1,5 +1,4 @@ -
- {{-- TODO: CHeck changes from old order-success for wording and html structure --}} +
{{-- Hero Section --}}
Date: Tue, 29 Apr 2025 11:38:08 -0400 Subject: [PATCH 24/26] show repo access instructions for Max subscribers --- app/Enums/Subscription.php | 58 +++++++++++++++++++ app/Jobs/CreateAnystackLicenseJob.php | 9 +-- .../HandleCustomerSubscriptionCreatedJob.php | 9 +-- app/Livewire/OrderSuccess.php | 24 ++++++++ app/Notifications/LicenseKeyGenerated.php | 3 + app/Support/Stripe/StripePrice.php | 53 ----------------- config/subscriptions.php | 6 +- .../views/livewire/order-success.blade.php | 51 +++++++++++++++- 8 files changed, 145 insertions(+), 68 deletions(-) create mode 100644 app/Enums/Subscription.php delete mode 100644 app/Support/Stripe/StripePrice.php diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php new file mode 100644 index 00000000..785fddd0 --- /dev/null +++ b/app/Enums/Subscription.php @@ -0,0 +1,58 @@ +items->first()?->price->id; + + if (! $priceId) { + throw new RuntimeException('Could not resolve Stripe price id from subscription object.'); + } + + return self::fromStripePriceId($priceId); + } + + public static function fromStripePriceId(string $priceId): self + { + return match ($priceId) { + config('subscriptions.plans.mini.stripe_price_id') => self::Mini, + config('subscriptions.plans.pro.stripe_price_id') => self::Pro, + config('subscriptions.plans.max.stripe_price_id') => self::Max, + default => throw new RuntimeException("Unknown Stripe price id: {$priceId}"), + }; + } + + public function name(): string + { + return config("subscriptions.plans.{$this->value}.name"); + } + + public function stripePriceId(): string + { + return config("subscriptions.plans.{$this->value}.stripe_price_id"); + } + + public function stripePaymentLink(): string + { + return config("subscriptions.plans.{$this->value}.stripe_payment_link"); + } + + public function anystackProductId(): string + { + return config("subscriptions.plans.{$this->value}.anystack_product_id"); + } + + public function anystackPolicyId(): string + { + return config("subscriptions.plans.{$this->value}.anystack_policy_id"); + } +} diff --git a/app/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php index f56223fd..5f902ac7 100644 --- a/app/Jobs/CreateAnystackLicenseJob.php +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Enums\Subscription; use App\Notifications\LicenseKeyGenerated; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,8 +20,7 @@ class CreateAnystackLicenseJob implements ShouldQueue public function __construct( public string $email, - public string $productId, - public string $policyId, + public Subscription $subscription, public ?string $firstName = null, public ?string $lastName = null, ) {} @@ -36,6 +36,7 @@ public function handle(): void Notification::route('mail', $this->email) ->notify(new LicenseKeyGenerated( $license['key'], + $this->subscription, $this->firstName )); } @@ -61,12 +62,12 @@ private function createContact(): array private function createLicense(string $contactId): ?array { $data = [ - 'policy_id' => $this->policyId, + 'policy_id' => $this->subscription->anystackPolicyId(), 'contact_id' => $contactId, ]; return $this->anystackClient() - ->post("https://api.anystack.sh/v1/products/{$this->productId}/licenses", $data) + ->post("https://api.anystack.sh/v1/products/{$this->subscription->anystackProductId()}/licenses", $data) ->throw() ->json('data'); } diff --git a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php index bbe1d8e4..dc604314 100644 --- a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php +++ b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php @@ -3,7 +3,6 @@ namespace App\Jobs\StripeWebhooks; use App\Jobs\CreateAnystackLicenseJob; -use App\Support\Stripe\StripePrice; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -40,10 +39,7 @@ public function handle(): void return; } - $price = $stripeSubscription->items->first()?->price; - - $productId = StripePrice::from($price)->getAnystackProductId(); - $policyId = StripePrice::from($price)->getAnystackPolicyId(); + $subscriptionPlan = \App\Enums\Subscription::fromStripeSubscription($stripeSubscription); $nameParts = explode(' ', $customer->name ?? '', 2); $firstName = $nameParts[0] ?: null; @@ -51,8 +47,7 @@ public function handle(): void dispatch(new CreateAnystackLicenseJob( $email, - $productId, - $policyId, + $subscriptionPlan, $firstName, $lastName, )); diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php index 6126b54f..70ed2034 100644 --- a/app/Livewire/OrderSuccess.php +++ b/app/Livewire/OrderSuccess.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Enums\Subscription; use Illuminate\Support\Facades\Cache; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -16,6 +17,8 @@ class OrderSuccess extends Component public ?string $licenseKey = null; + public ?Subscription $subscription = null; + public string $checkoutSessionId; public function mount(string $checkoutSessionId): void @@ -29,6 +32,7 @@ public function loadData(): void { $this->email = $this->loadEmail(); $this->licenseKey = $this->loadLicenseKey(); + $this->subscription = $this->loadSubscription(); } private function loadEmail(): ?string @@ -65,4 +69,24 @@ private function loadLicenseKey(): ?string return $licenseKey; } + + private function loadSubscription(): ?Subscription + { + if ($subscription = session('subscription')) { + return Subscription::tryFrom($subscription); + } + + $stripe = app(StripeClient::class); + $priceId = $stripe->checkout->sessions->allLineItems($this->checkoutSessionId)->first()?->price->id; + + if (! $priceId) { + return null; + } + + $subscription = Subscription::fromStripePriceId($priceId); + + session()->put('subscription', $subscription->value); + + return $subscription; + } } diff --git a/app/Notifications/LicenseKeyGenerated.php b/app/Notifications/LicenseKeyGenerated.php index ef122cbb..7ece8ecb 100644 --- a/app/Notifications/LicenseKeyGenerated.php +++ b/app/Notifications/LicenseKeyGenerated.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Enums\Subscription; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; @@ -13,6 +14,7 @@ class LicenseKeyGenerated extends Notification implements ShouldQueue public function __construct( public string $licenseKey, + public ?Subscription $subscription = null, public ?string $firstName = null, ) {} @@ -41,6 +43,7 @@ public function toMail(object $notifiable): MailMessage ->line('When prompted by Composer, use your email address as the username and this license key as the password.') ->action('View Installation Guide', url('/docs/mobile/1/getting-started/installation')) ->line("If you have any questions, please don't hesitate to reach out to our support team.") + ->lineIf($this->subscription === Subscription::Max, 'As a Max subscriber, you also have access to the NativePHP/mobile repository. To access it, please log in to [Anystack.sh](https://auth.anystack.sh/?accountType=customer) using the same email address you used for your purchase.') ->salutation("Happy coding!\n\nThe NativePHP Team") ->success(); } diff --git a/app/Support/Stripe/StripePrice.php b/app/Support/Stripe/StripePrice.php deleted file mode 100644 index 87e08be6..00000000 --- a/app/Support/Stripe/StripePrice.php +++ /dev/null @@ -1,53 +0,0 @@ -getPlan()['anystack_product_id']; - } - - public function getAnystackPolicyId(): string - { - return $this->getPlan()['anystack_policy_id']; - } - - public function getPlanName(): string - { - return $this->getPlan()['name']; - } - - public function getPlan(): array - { - return $this->plan ?? $this->resolvePlan(); - } - - protected function resolvePlan(): array - { - $plan = Arr::first( - config('subscriptions.plans'), - fn ($value, $key): bool => $value['stripe_price_id'] === $this->price->id - ); - - if (! $plan) { - throw new Exception("Could not resolve plan for Stripe price_id: {$this->price->id}"); - } - - return $plan; - } -} diff --git a/config/subscriptions.php b/config/subscriptions.php index cfb26c80..ea5c16ad 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -2,21 +2,21 @@ return [ 'plans' => [ - 'mini' => [ + \App\Enums\Subscription::Mini->value => [ 'name' => 'Early Access (Mini)', 'stripe_price_id' => env('STRIPE_MINI_PRICE_ID'), 'stripe_payment_link' => env('STRIPE_MINI_PAYMENT_LINK'), 'anystack_product_id' => env('ANYSTACK_MINI_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_MINI_POLICY_ID'), ], - 'pro' => [ + \App\Enums\Subscription::Pro->value => [ 'name' => 'Early Access (Pro)', 'stripe_price_id' => env('STRIPE_PRO_PRICE_ID'), 'stripe_payment_link' => env('STRIPE_PRO_PAYMENT_LINK'), 'anystack_product_id' => env('ANYSTACK_PRO_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_PRO_POLICY_ID'), ], - 'max' => [ + \App\Enums\Subscription::Max->value => [ 'name' => 'Early Access (Max)', 'stripe_price_id' => env('STRIPE_MAX_PRICE_ID'), 'stripe_payment_link' => env('STRIPE_MAX_PAYMENT_LINK'), diff --git a/resources/views/livewire/order-success.blade.php b/resources/views/livewire/order-success.blade.php index 95454a45..e8618274 100644 --- a/resources/views/livewire/order-success.blade.php +++ b/resources/views/livewire/order-success.blade.php @@ -1,4 +1,4 @@ -
+
{{-- Hero Section --}}
View Installation Guide
+ + @if ($subscription === \App\Enums\Subscription::Max) +
+
+
+ +
+
+

+ Repo Access +

+
+

+ As a Max subscriber, you have access to + the NativePHP/mobile repository. To + access it, please log in to + + AnyStack.sh + using the same email address you used + for your purchase. +

+
+
+
+
+ @endif
From afe5ae95c63f08f7e950732c4a054ecc9f745dec Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:38:20 -0400 Subject: [PATCH 25/26] update tests --- .../Jobs/CreateAnystackLicenseJobTest.php | 23 +++++++------- ...ndleCustomerSubscriptionCreatedJobTest.php | 28 ++++------------- tests/Feature/Livewire/OrderSuccessTest.php | 31 +++++++++++++++++-- 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php index 32e5725c..05747d25 100644 --- a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php +++ b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Jobs; +use App\Enums\Subscription; use App\Jobs\CreateAnystackLicenseJob; use App\Notifications\LicenseKeyGenerated; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -46,8 +47,7 @@ public function it_creates_contact_and_license_on_anystack() { $job = new CreateAnystackLicenseJob( 'test@example.com', - 'product-123', - 'policy-123', + Subscription::Max, 'John', 'Doe' ); @@ -64,11 +64,13 @@ public function it_creates_contact_and_license_on_anystack() ]; }); - Http::assertSent(function ($request) { - return $request->url() === 'https://api.anystack.sh/v1/products/product-123/licenses' && + $productId = Subscription::Max->anystackProductId(); + + Http::assertSent(function ($request) use ($productId) { + return $request->url() === "https://api.anystack.sh/v1/products/$productId/licenses" && $request->method() === 'POST' && $request->data() === [ - 'policy_id' => 'policy-123', + 'policy_id' => Subscription::Max->anystackPolicyId(), 'contact_id' => 'contact-123', ]; }); @@ -79,8 +81,7 @@ public function it_stores_license_key_in_cache() { $job = new CreateAnystackLicenseJob( 'test@example.com', - 'product-123', - 'policy-123', + Subscription::Max, 'John', 'Doe' ); @@ -95,8 +96,7 @@ public function it_sends_license_key_notification() { $job = new CreateAnystackLicenseJob( 'test@example.com', - 'product-123', - 'policy-123', + Subscription::Max, 'John', 'Doe' ); @@ -108,6 +108,7 @@ public function it_sends_license_key_notification() function (LicenseKeyGenerated $notification, array $channels, object $notifiable) { return $notifiable->routes['mail'] === 'test@example.com' && $notification->licenseKey === 'test-license-key-12345' && + $notification->subscription === Subscription::Max && $notification->firstName === 'John'; } ); @@ -119,8 +120,7 @@ public function it_handles_missing_name_components() // Create and run the job with missing name components $job = new CreateAnystackLicenseJob( 'test@example.com', - 'product-123', - 'policy-123' + Subscription::Max, ); $job->handle(); @@ -140,6 +140,7 @@ public function it_handles_missing_name_components() function (LicenseKeyGenerated $notification, array $channels, object $notifiable) { return $notifiable->routes['mail'] === 'test@example.com' && $notification->licenseKey === 'test-license-key-12345' && + $notification->subscription === Subscription::Max && $notification->firstName === null; } ); diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php index 42a565e2..d0b1897c 100644 --- a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -2,11 +2,11 @@ namespace Tests\Feature\Jobs; +use App\Enums\Subscription; use App\Jobs\CreateAnystackLicenseJob; use App\Jobs\StripeWebhooks\HandleCustomerSubscriptionCreatedJob; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\Config; use Spatie\WebhookClient\Models\WebhookCall; use Stripe\Customer; use Stripe\StripeClient; @@ -16,21 +16,6 @@ class HandleCustomerSubscriptionCreatedJobTest extends TestCase { use RefreshDatabase; - protected function setUp(): void - { - parent::setUp(); - - // Set up test configuration for Stripe prices - Config::set('subscriptions.plans', [ - 'test' => [ - 'name' => 'Test Plan', - 'stripe_price_id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', - 'anystack_product_id' => 'test-product-id', - 'anystack_policy_id' => 'test-policy-id', - ], - ]); - } - /** @test */ public function it_dispatches_the_create_anystack_license_job_with_correct_data() { @@ -57,10 +42,9 @@ public function it_dispatches_the_create_anystack_license_job_with_correct_data( $job->handle(); // Assert that the CreateAnystackLicenseJob was dispatched with the correct parameters - Bus::assertDispatched(CreateAnystackLicenseJob::class, function ($job) { + Bus::assertDispatched(CreateAnystackLicenseJob::class, function (CreateAnystackLicenseJob $job) { return $job->email === 'test@example.com' && - $job->productId === 'test-product-id' && - $job->policyId === 'test-policy-id' && + $job->subscription === Subscription::Max && $job->firstName === 'John' && $job->lastName === 'Doe'; }); @@ -195,7 +179,7 @@ protected function getTestWebhookPayload(): array 'discounts' => [], 'metadata' => [], 'plan' => [ - 'id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'id' => Subscription::Max->stripePriceId(), 'object' => 'plan', 'active' => true, 'aggregate_usage' => null, @@ -217,7 +201,7 @@ protected function getTestWebhookPayload(): array 'usage_type' => 'licensed', ], 'price' => [ - 'id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'id' => Subscription::Max->stripePriceId(), 'object' => 'price', 'active' => true, 'billing_scheme' => 'per_unit', @@ -279,7 +263,7 @@ protected function getTestWebhookPayload(): array 'pending_setup_intent' => null, 'pending_update' => null, 'plan' => [ - 'id' => 'price_1RFFxGAyFo6rlwXqt5B1eNEF', + 'id' => Subscription::Max->stripePriceId(), 'object' => 'plan', 'active' => true, 'aggregate_usage' => null, diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php index 82131cdb..49517bcc 100644 --- a/tests/Feature/Livewire/OrderSuccessTest.php +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -2,12 +2,15 @@ namespace Tests\Feature\Livewire; +use App\Enums\Subscription; use App\Livewire\OrderSuccess; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Session; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; use Stripe\Checkout\Session as CheckoutSession; +use Stripe\Collection; +use Stripe\LineItem; use Stripe\StripeClient; use Tests\TestCase; @@ -95,6 +98,22 @@ private function mockStripeClient(): void ], ]); + $mockCheckoutSessionLineItems = Collection::constructFrom([ + 'object' => 'list', + 'data' => [ + LineItem::constructFrom([ + 'id' => 'li_1RFKPpAyFo6rlwXqAHI9wA95', + 'object' => 'item', + 'description' => 'Early Access Program (Max)', + 'price' => [ + 'id' => Subscription::Max->stripePriceId(), + 'object' => 'price', + 'product' => 'prod_S9Z5CgycbP7P4y', + ], + ]), + ], + ]); + $mockStripeClient = $this->createMock(StripeClient::class); $mockStripeClient->checkout = new class($mockCheckoutSession) @@ -107,19 +126,27 @@ public function __construct($mockCheckoutSession) } }; - $mockStripeClient->checkout->sessions = new class($mockCheckoutSession) + $mockStripeClient->checkout->sessions = new class($mockCheckoutSession, $mockCheckoutSessionLineItems) { private $mockCheckoutSession; - public function __construct($mockCheckoutSession) + private $mockCheckoutSessionLineItems; + + public function __construct($mockCheckoutSession, $mockCheckoutSessionLineItems) { $this->mockCheckoutSession = $mockCheckoutSession; + $this->mockCheckoutSessionLineItems = $mockCheckoutSessionLineItems; } public function retrieve() { return $this->mockCheckoutSession; } + + public function allLineItems() + { + return $this->mockCheckoutSessionLineItems; + } }; $this->app->instance(StripeClient::class, $mockStripeClient); From e1fc573012dfe8a39a87b0960e143756a87472c3 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:40:24 -0400 Subject: [PATCH 26/26] change "beta" to "Early Access" --- resources/views/livewire/order-success.blade.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/order-success.blade.php b/resources/views/livewire/order-success.blade.php index e8618274..222b4109 100644 --- a/resources/views/livewire/order-success.blade.php +++ b/resources/views/livewire/order-success.blade.php @@ -507,8 +507,9 @@ class="mb-4 flex h-12 w-12 items-center justify-center rounded-full"

Share Your Feedback

- We're currently in beta and constantly improving. Let us - know if you find any bugs as you build. + We're currently in Early Access and constantly + improving. Let us know if you find any bugs as you + build.