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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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.

From 842bffa2f66ac079b9c15df02f5c605e98da6243 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 30 Apr 2025 13:08:55 -0400 Subject: [PATCH 27/37] convert to cashier with user creation upon purchase --- app/Jobs/CreateUserFromStripeCustomer.php | 45 ++ .../HandleCustomerSubscriptionCreatedJob.php | 22 +- .../StripeWebhookHandledListener.php | 20 + .../StripeWebhookReceivedListener.php | 25 + app/Models/User.php | 24 +- app/Providers/EventServiceProvider.php | 10 + composer.json | 8 +- composer.lock | 481 ++++++++++-------- config/cashier.php | 127 +++++ config/stripe-webhooks.php | 50 -- database/factories/UserFactory.php | 3 +- ...9_05_03_000001_create_customer_columns.php | 40 ++ ...5_03_000002_create_subscriptions_table.php | 37 ++ ...000003_create_subscription_items_table.php | 34 ++ ...4_17_185913_create_webhook_calls_table.php | 23 - .../2025_04_30_135437_alter_users_table.php | 22 + routes/web.php | 1 - .../Jobs/CreateUserFromStripeCustomerTest.php | 99 ++++ ...ndleCustomerSubscriptionCreatedJobTest.php | 53 +- .../Stripe/StripeWebhookConfigurationTest.php | 21 - tests/Feature/StripePurchaseHandlingTest.php | 132 +++++ .../{Stripe => }/StripeWebhookRouteTest.php | 2 +- 22 files changed, 903 insertions(+), 376 deletions(-) create mode 100644 app/Jobs/CreateUserFromStripeCustomer.php rename app/Jobs/{StripeWebhooks => }/HandleCustomerSubscriptionCreatedJob.php (63%) create mode 100644 app/Listeners/StripeWebhookHandledListener.php create mode 100644 app/Listeners/StripeWebhookReceivedListener.php create mode 100644 config/cashier.php delete mode 100644 config/stripe-webhooks.php create mode 100644 database/migrations/2019_05_03_000001_create_customer_columns.php create mode 100644 database/migrations/2019_05_03_000002_create_subscriptions_table.php create mode 100644 database/migrations/2019_05_03_000003_create_subscription_items_table.php delete mode 100644 database/migrations/2025_04_17_185913_create_webhook_calls_table.php create mode 100644 database/migrations/2025_04_30_135437_alter_users_table.php create mode 100644 tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php delete mode 100644 tests/Feature/Stripe/StripeWebhookConfigurationTest.php create mode 100644 tests/Feature/StripePurchaseHandlingTest.php rename tests/Feature/{Stripe => }/StripeWebhookRouteTest.php (95%) diff --git a/app/Jobs/CreateUserFromStripeCustomer.php b/app/Jobs/CreateUserFromStripeCustomer.php new file mode 100644 index 00000000..e383259c --- /dev/null +++ b/app/Jobs/CreateUserFromStripeCustomer.php @@ -0,0 +1,45 @@ +customer)) { + $this->fail("A user already exists for Stripe customer [{$this->customer->id}]."); + + return; + } + + if (User::query()->where('email', $this->customer->email)->exists()) { + $this->fail("A user already exists for email [{$this->customer->email}]."); + + return; + } + + $user = new User; + $user->name = $this->customer->name; + $user->email = $this->customer->email; + $user->stripe_id = $this->customer->id; + // We will create a random password for the user and expect them to reset it. + $user->password = Hash::make(Str::random(72)); + + $user->save(); + } +} diff --git a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php similarity index 63% rename from app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php rename to app/Jobs/HandleCustomerSubscriptionCreatedJob.php index dc604314..78941ef4 100644 --- a/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php +++ b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php @@ -1,23 +1,21 @@ customers - ->retrieve($stripeSubscription->customer); + $user = Cashier::findBillable($stripeSubscription->customer); - if (! $customer || ! ($email = $customer->email)) { - $this->fail('Failed to retrieve customer information or customer has no email.'); + if (! $user || ! ($email = $user->email)) { + $this->fail('Failed to find user from Stripe subscription customer.'); return; } $subscriptionPlan = \App\Enums\Subscription::fromStripeSubscription($stripeSubscription); - $nameParts = explode(' ', $customer->name ?? '', 2); + $nameParts = explode(' ', $user->name ?? '', 2); $firstName = $nameParts[0] ?: null; $lastName = $nameParts[1] ?? null; @@ -55,6 +51,6 @@ public function handle(): void protected function constructStripeSubscription(): ?Subscription { - return Event::constructFrom($this->webhook->payload)->data?->object; + return Subscription::constructFrom($this->webhook->payload['data']['object']); } } diff --git a/app/Listeners/StripeWebhookHandledListener.php b/app/Listeners/StripeWebhookHandledListener.php new file mode 100644 index 00000000..d91ac73f --- /dev/null +++ b/app/Listeners/StripeWebhookHandledListener.php @@ -0,0 +1,20 @@ +payload); + + match ($event->payload['type']) { + 'customer.subscription.created' => dispatch(new HandleCustomerSubscriptionCreatedJob($event)), + default => null, + }; + } +} diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php new file mode 100644 index 00000000..7ccfd1a3 --- /dev/null +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -0,0 +1,25 @@ +payload); + + match ($event->payload['type']) { + // 'customer.created' must be dispatched sync so the user is + // created before the cashier webhook handling is executed. + 'customer.created' => dispatch_sync(new CreateUserFromStripeCustomer( + Customer::constructFrom($event->payload['data']['object']) + )), + default => null, + }; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4d7f70f5..cffc680c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,38 +6,20 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { - use HasApiTokens, HasFactory, Notifiable; + use Billable, HasApiTokens, HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var array - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; + protected $guarded = []; - /** - * The attributes that should be hidden for serialization. - * - * @var array - */ protected $hidden = [ 'password', 'remember_token', ]; - /** - * The attributes that should be cast. - * - * @var array - */ protected $casts = [ 'email_verified_at' => 'datetime', 'password' => 'hashed', diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 2d65aac0..84124625 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,10 +2,14 @@ namespace App\Providers; +use App\Listeners\StripeWebhookHandledListener; +use App\Listeners\StripeWebhookReceivedListener; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Event; +use Laravel\Cashier\Events\WebhookHandled; +use Laravel\Cashier\Events\WebhookReceived; class EventServiceProvider extends ServiceProvider { @@ -18,6 +22,12 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + WebhookReceived::class => [ + StripeWebhookReceivedListener::class, + ], + WebhookHandled::class => [ + StripeWebhookHandledListener::class, + ], ]; /** diff --git a/composer.json b/composer.json index 269a702e..837507ce 100644 --- a/composer.json +++ b/composer.json @@ -2,22 +2,24 @@ "name": "nativephp/nativephp.com", "type": "project", "description": "The NativePHP website", - "keywords": ["laravel", "nativephp"], + "keywords": [ + "laravel", + "nativephp" + ], "license": "MIT", "require": { "php": "^8.1", "artesaos/seotools": "^1.2", "blade-ui-kit/blade-heroicons": "^2.3", "guzzlehttp/guzzle": "^7.2", + "laravel/cashier": "^15.6", "laravel/framework": "^10.10", "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", - "stripe/stripe-php": "^17.1", "torchlight/torchlight-commonmark": "^0.5.5" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 20d8d481..d582e2c8 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": "808f9210731b7db556da0cbf1b41bca7", + "content-hash": "191efc8e48d010e853d4bcb87652ca95", "packages": [ { "name": "artesaos/seotools", @@ -1275,6 +1275,94 @@ ], "time": "2025-02-03T10:55:03+00:00" }, + { + "name": "laravel/cashier", + "version": "v15.6.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "8fe60cc71161ef06b6a1b23cffe886abf2a49b29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/8fe60cc71161ef06b6a1b23cffe886abf2a49b29", + "reference": "8fe60cc71161ef06b6a1b23cffe886abf2a49b29", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/log": "^10.0|^11.0|^12.0", + "illuminate/notifications": "^10.0|^11.0|^12.0", + "illuminate/pagination": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/view": "^10.0|^11.0|^12.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^16.2", + "symfony/console": "^6.0|^7.0", + "symfony/http-kernel": "^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.22.1" + }, + "require-dev": { + "dompdf/dompdf": "^2.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^8.18|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.4|^11.5" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^1.0.1|^2.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "15.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2025-04-22T13:59:36+00:00" + }, { "name": "laravel/framework", "version": "v10.48.29", @@ -2186,6 +2274,96 @@ ], "time": "2025-04-12T22:26:52+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.7.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "af048f0206d3b39b8fad9de6a230cedf765365fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/af048f0206d3b39b8fad9de6a230cedf765365fa", + "reference": "af048f0206d3b39b8fad9de6a230cedf765365fa", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.7.0" + }, + "time": "2025-04-03T08:26:36+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -3538,209 +3716,6 @@ ], "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", @@ -3982,16 +3957,16 @@ }, { "name": "stripe/stripe-php", - "version": "v17.1.1", + "version": "v16.6.0", "source": { "type": "git", "url": "https://github.com/stripe/stripe-php.git", - "reference": "01ca9b5fdd899b8e4b69f83b85e09d96f6240220" + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/stripe/stripe-php/zipball/01ca9b5fdd899b8e4b69f83b85e09d96f6240220", - "reference": "01ca9b5fdd899b8e4b69f83b85e09d96f6240220", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", "shasum": "" }, "require": { @@ -4001,7 +3976,7 @@ "php": ">=5.6.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "3.72.0", + "friendsofphp/php-cs-fixer": "3.5.0", "phpstan/phpstan": "^1.2", "phpunit/phpunit": "^5.7 || ^9.0" }, @@ -4035,9 +4010,9 @@ ], "support": { "issues": "https://github.com/stripe/stripe-php/issues", - "source": "https://github.com/stripe/stripe-php/tree/v17.1.1" + "source": "https://github.com/stripe/stripe-php/tree/v16.6.0" }, - "time": "2025-04-05T00:09:14+00:00" + "time": "2025-02-24T22:35:29+00:00" }, { "name": "symfony/console", @@ -5073,6 +5048,90 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78", + "reference": "d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.31.0", diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 00000000..4a9b024b --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,127 @@ + env('STRIPE_KEY'), + + 'secret' => env('STRIPE_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Cashier Path + |-------------------------------------------------------------------------- + | + | This is the base URI path where Cashier's views, such as the payment + | verification screen, will be available from. You're free to tweak + | this path according to your preferences and application design. + | + */ + + 'path' => env('CASHIER_PATH', 'stripe'), + + /* + |-------------------------------------------------------------------------- + | Stripe Webhooks + |-------------------------------------------------------------------------- + | + | Your Stripe webhook secret is used to prevent unauthorized requests to + | your Stripe webhook handling controllers. The tolerance setting will + | check the drift between the current time and the signed request's. + | + */ + + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + 'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300), + 'events' => WebhookCommand::DEFAULT_EVENTS, + ], + + /* + |-------------------------------------------------------------------------- + | Currency + |-------------------------------------------------------------------------- + | + | This is the default currency that will be used when generating charges + | from your application. Of course, you are welcome to use any of the + | various world currencies that are currently supported via Stripe. + | + */ + + 'currency' => env('CASHIER_CURRENCY', 'usd'), + + /* + |-------------------------------------------------------------------------- + | Currency Locale + |-------------------------------------------------------------------------- + | + | This is the default locale in which your money values are formatted in + | for display. To utilize other locales besides the default en locale + | verify you have the "intl" PHP extension installed on the system. + | + */ + + 'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'), + + /* + |-------------------------------------------------------------------------- + | Payment Confirmation Notification + |-------------------------------------------------------------------------- + | + | If this setting is enabled, Cashier will automatically notify customers + | whose payments require additional verification. You should listen to + | Stripe's webhooks in order for this feature to function correctly. + | + */ + + 'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'), + + /* + |-------------------------------------------------------------------------- + | Invoice Settings + |-------------------------------------------------------------------------- + | + | The following options determine how Cashier invoices are converted from + | HTML into PDFs. You're free to change the options based on the needs + | of your application or your preferences regarding invoice styling. + | + */ + + 'invoices' => [ + 'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class), + + 'options' => [ + // Supported: 'letter', 'legal', 'A4' + 'paper' => env('CASHIER_PAPER', 'letter'), + + 'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Stripe Logger + |-------------------------------------------------------------------------- + | + | This setting defines which logging channel will be used by the Stripe + | library to write log messages. You are free to specify any of your + | logging channels listed inside the "logging" configuration file. + | + */ + + 'logger' => env('CASHIER_LOGGER'), + +]; diff --git a/config/stripe-webhooks.php b/config/stripe-webhooks.php deleted file mode 100644 index 6540941a..00000000 --- a/config/stripe-webhooks.php +++ /dev/null @@ -1,50 +0,0 @@ - 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/factories/UserFactory.php b/database/factories/UserFactory.php index a6ecc0af..ca9d62a8 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -3,6 +3,7 @@ namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; /** @@ -21,7 +22,7 @@ public function definition(): array 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'password' => Hash::make('password'), 'remember_token' => Str::random(10), ]; } diff --git a/database/migrations/2019_05_03_000001_create_customer_columns.php b/database/migrations/2019_05_03_000001_create_customer_columns.php new file mode 100644 index 00000000..974b381e --- /dev/null +++ b/database/migrations/2019_05_03_000001_create_customer_columns.php @@ -0,0 +1,40 @@ +string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex([ + 'stripe_id', + ]); + + $table->dropColumn([ + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'trial_ends_at', + ]); + }); + } +}; diff --git a/database/migrations/2019_05_03_000002_create_subscriptions_table.php b/database/migrations/2019_05_03_000002_create_subscriptions_table.php new file mode 100644 index 00000000..ccbcc6dd --- /dev/null +++ b/database/migrations/2019_05_03_000002_create_subscriptions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id'); + $table->string('type'); + $table->string('stripe_id')->unique(); + $table->string('stripe_status'); + $table->string('stripe_price')->nullable(); + $table->integer('quantity')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'stripe_status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2019_05_03_000003_create_subscription_items_table.php b/database/migrations/2019_05_03_000003_create_subscription_items_table.php new file mode 100644 index 00000000..420e23f0 --- /dev/null +++ b/database/migrations/2019_05_03_000003_create_subscription_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('subscription_id'); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->integer('quantity')->nullable(); + $table->timestamps(); + + $table->index(['subscription_id', 'stripe_price']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscription_items'); + } +}; 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 deleted file mode 100644 index db97aceb..00000000 --- a/database/migrations/2025_04_17_185913_create_webhook_calls_table.php +++ /dev/null @@ -1,23 +0,0 @@ -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/database/migrations/2025_04_30_135437_alter_users_table.php b/database/migrations/2025_04_30_135437_alter_users_table.php new file mode 100644 index 00000000..b4bf055f --- /dev/null +++ b/database/migrations/2025_04_30_135437_alter_users_table.php @@ -0,0 +1,22 @@ +string('name')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('name')->nullable(); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 13cae0ac..2e2a5420 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,4 +52,3 @@ })->name('docs')->where('page', '.*'); Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); -Route::stripeWebhooks('stripe/webhook'); diff --git a/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php b/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php new file mode 100644 index 00000000..6bbb3dfd --- /dev/null +++ b/tests/Feature/Jobs/CreateUserFromStripeCustomerTest.php @@ -0,0 +1,99 @@ + 'cus_minimal123', + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $job = new CreateUserFromStripeCustomer($customer); + $job->handle(); + + $user = User::where('email', 'test@example.com')->first(); + + $this->assertNotNull($user); + $this->assertEquals('Test User', $user->name); + $this->assertEquals('test@example.com', $user->email); + $this->assertEquals('cus_minimal123', $user->stripe_id); + + $this->assertNotNull($user->password); + $this->assertTrue(Hash::isHashed($user->password)); + } + + /** @test */ + public function it_fails_when_a_user_with_the_same_stripe_id_already_exists() + { + $existingUser = User::factory()->create([ + 'stripe_id' => 'cus_existing123', + ]); + + $customer = Customer::constructFrom([ + 'id' => 'cus_existing123', + 'name' => 'Another User', + 'email' => 'another@example.com', + ]); + + $job = new CreateUserFromStripeCustomer($customer); + + $job->handle(); + + $this->assertDatabaseCount('users', 1); + $this->assertEquals($existingUser->id, User::first()->id); + } + + /** @test */ + public function it_fails_when_a_user_with_the_same_email_already_exists() + { + $existingUser = User::factory()->create([ + 'email' => 'existing@example.com', + ]); + + $customer = Customer::constructFrom([ + 'id' => 'cus_existing123', + 'name' => 'Another User', + 'email' => 'existing@example.com', + ]); + + $job = new CreateUserFromStripeCustomer($customer); + + $job->handle(); + + $this->assertDatabaseCount('users', 1); + $this->assertEquals($existingUser->id, User::first()->id); + } + + /** @test */ + public function it_handles_a_null_name_in_stripe_customer() + { + $customer = Customer::constructFrom([ + 'id' => 'cus_noname123', + 'name' => null, + 'email' => 'noname@example.com', + ]); + + $job = new CreateUserFromStripeCustomer($customer); + $job->handle(); + + $this->assertDatabaseHas('users', [ + 'name' => null, + 'email' => 'noname@example.com', + 'stripe_id' => 'cus_noname123', + ]); + } +} diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php index d0b1897c..2b1ba3c6 100644 --- a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -4,10 +4,11 @@ use App\Enums\Subscription; use App\Jobs\CreateAnystackLicenseJob; -use App\Jobs\StripeWebhooks\HandleCustomerSubscriptionCreatedJob; +use App\Jobs\CreateUserFromStripeCustomer; +use App\Jobs\HandleCustomerSubscriptionCreatedJob; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; -use Spatie\WebhookClient\Models\WebhookCall; +use Laravel\Cashier\Events\WebhookHandled; use Stripe\Customer; use Stripe\StripeClient; use Tests\TestCase; @@ -19,29 +20,23 @@ class HandleCustomerSubscriptionCreatedJobTest extends TestCase /** @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([ + 'id' => 'cus_S9dhoV2rJK2Auy', '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(), - ]); + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + Bus::fake(); + + $webhookCall = new WebhookHandled($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 (CreateAnystackLicenseJob $job) { return $job->email === 'test@example.com' && $job->subscription === Subscription::Max && @@ -57,21 +52,19 @@ public function it_dispatches_the_create_anystack_license_job_with_correct_data( */ public function it_extracts_customer_name_parts_correctly($fullName, $expectedFirstName, $expectedLastName) { - Bus::fake(); - $mockCustomer = Customer::constructFrom([ + 'id' => 'cus_S9dhoV2rJK2Auy', '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(), - ]); + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + $webhookCall = new WebhookHandled($this->getTestWebhookPayload()); + + Bus::fake(); $job = new HandleCustomerSubscriptionCreatedJob($webhookCall); $job->handle(); @@ -98,21 +91,19 @@ public static function customerNameProvider() /** @test */ public function it_fails_when_customer_has_no_email() { - Bus::fake(); - $mockCustomer = Customer::constructFrom([ - 'email' => null, + 'id' => 'cus_S9dhoV2rJK2Auy', + 'email' => '', 'name' => 'John Doe', ]); $this->mockStripeClient($mockCustomer); - $webhookCall = WebhookCall::make()->forceFill([ - 'name' => 'stripe', - 'url' => 'https://example.com/webhook', - 'headers' => ['Stripe-Signature' => 'test'], - 'payload' => $this->getTestWebhookPayload(), - ]); + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + Bus::fake(); + + $webhookCall = new WebhookHandled($this->getTestWebhookPayload()); $job = new HandleCustomerSubscriptionCreatedJob($webhookCall); $job->handle(); diff --git a/tests/Feature/Stripe/StripeWebhookConfigurationTest.php b/tests/Feature/Stripe/StripeWebhookConfigurationTest.php deleted file mode 100644 index 5cc647b7..00000000 --- a/tests/Feature/Stripe/StripeWebhookConfigurationTest.php +++ /dev/null @@ -1,21 +0,0 @@ -assertArrayHasKey('customer_subscription_created', $stripeWebhookConfig); - $this->assertEquals( - HandleCustomerSubscriptionCreatedJob::class, - $stripeWebhookConfig['customer_subscription_created'] - ); - } -} diff --git a/tests/Feature/StripePurchaseHandlingTest.php b/tests/Feature/StripePurchaseHandlingTest.php new file mode 100644 index 00000000..ca8c18f7 --- /dev/null +++ b/tests/Feature/StripePurchaseHandlingTest.php @@ -0,0 +1,132 @@ +set('cashier.webhook.secret', null); + + Http::fake([ + 'https://api.anystack.sh/v1/contacts' => Http::response(['data' => ['id' => 'contact-123']], 200), + 'https://api.anystack.sh/v1/products/*/licenses' => Http::response(['data' => ['key' => 'test-license-key-12345']], 200), + ]); + } + + #[Test] + public function a_user_is_created_when_a_stripe_customer_is_created() + { + Bus::fake(); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'customer.created', + 'data' => [ + 'object' => [ + 'id' => 'cus_test123', + 'name' => 'Test Customer', + 'email' => 'test@example.com', + ], + ], + ]; + + $this->postJson('/stripe/webhook', $payload); + + Bus::assertDispatched(CreateUserFromStripeCustomer::class); + } + + #[Test] + public function a_license_is_created_when_a_stripe_subscription_is_created() + { + Bus::fake([CreateAnystackLicenseJob::class]); + + $user = User::factory()->create([ + 'stripe_id' => 'cus_test123', + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $this->mockStripeClient($user); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'id' => 'sub_test123', + 'customer' => 'cus_test123', + 'status' => 'active', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_test', + 'price' => [ + 'id' => Subscription::Max->stripePriceId(), + 'product' => 'prod_test', + ], + 'quantity' => 1, + ], + ], + ], + ], + ], + ]; + + $this->postJson('/stripe/webhook', $payload); + + Bus::assertDispatched(CreateAnystackLicenseJob::class, function ($job) { + return $job->email === 'john@example.com' && + $job->subscription === Subscription::Max && + $job->firstName === 'John' && + $job->lastName === 'Doe'; + }); + + $user->refresh(); + + $this->assertNotEmpty($user->subscriptions); + $this->assertNotEmpty($user->subscriptions->first()->items); + } + + protected function mockStripeClient(User $user): void + { + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->customers = new class($user) + { + private $user; + + public function __construct($user) + { + $this->user = $user; + } + + public function retrieve() + { + return Customer::constructFrom([ + 'id' => $this->user->stripe_id, + 'name' => $this->user->name, + 'email' => $this->user->email, + ]); + } + }; + + $this->app->instance(StripeClient::class, $mockStripeClient); + } +} diff --git a/tests/Feature/Stripe/StripeWebhookRouteTest.php b/tests/Feature/StripeWebhookRouteTest.php similarity index 95% rename from tests/Feature/Stripe/StripeWebhookRouteTest.php rename to tests/Feature/StripeWebhookRouteTest.php index f2a37ebf..9617b846 100644 --- a/tests/Feature/Stripe/StripeWebhookRouteTest.php +++ b/tests/Feature/StripeWebhookRouteTest.php @@ -1,6 +1,6 @@ Date: Wed, 30 Apr 2025 15:58:03 -0400 Subject: [PATCH 28/37] alter todo comment based on new knowledge --- 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 5f902ac7..e7e0c0ae 100644 --- a/app/Jobs/CreateAnystackLicenseJob.php +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -51,8 +51,8 @@ private function createContact(): array ->filter() ->all(); - // TODO: It's unknown what will happen if an existing contact with - // the same email address already exists. + // TODO: If an existing contact with the same email address already exists, + // anystack will return a 422 validation error response. return $this->anystackClient() ->post('https://api.anystack.sh/v1/contacts', $data) ->throw() From a0606fb2a35416f6ef022b7e0114de5e6fc406ec Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:02:47 -0400 Subject: [PATCH 29/37] update anystack references in installation.md --- .../views/docs/mobile/1/getting-started/installation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/views/docs/mobile/1/getting-started/installation.md b/resources/views/docs/mobile/1/getting-started/installation.md index 0ea3c11c..8756d09b 100644 --- a/resources/views/docs/mobile/1/getting-started/installation.md +++ b/resources/views/docs/mobile/1/getting-started/installation.md @@ -9,7 +9,7 @@ order: 100 2. Laravel 10 or higher 3. An Apple Silicon Mac running macOS 12+ with Xcode 16+ 4. An active [Apple Developer account](https://developer.apple.com/) -5. [A NativePHP for mobile license](https://checkout.anystack.sh/nativephp) +5. [A NativePHP for mobile license](https://nativephp.com/mobile) 6. _Optional_ iOS device @@ -59,7 +59,7 @@ To make NativePHP for mobile a reality has taken a lot of work and will continue it's not open source, and you are not free to distribute or modify its source code. Before you begin, you will need to purchase a license. -Licenses can be obtained via [Anystack](https://checkout.anystack.sh/nativephp). +Licenses can be obtained [here](https://nativephp.com/mobile). Once you have your license, you will need to add the following to your `composer.json`: @@ -78,7 +78,7 @@ composer require nativephp/mobile ``` If this is the first time you're installing the package, you will be prompted to authenticate. Your username is the -email address you registered with Anystack. Your password is your license key. +email address you used when purchasing your license. Your password is your license key. This package contains all the libraries, classes, commands, and interfaces that your application will need to work with iOS and Android. From e0d9afc9fd3ae92492f83f61d5e02a3b6dbd6341 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 10:49:04 -0400 Subject: [PATCH 30/37] handle merge conflict for composer.lock --- composer.lock | 399 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 398 insertions(+), 1 deletion(-) diff --git a/composer.lock b/composer.lock index 12277e27..01d9c17c 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": "1e617a603eb60be079042b7936a803f0", + "content-hash": "9a07fbaf2eb1d74147c0547034112ec7", "packages": [ { "name": "artesaos/seotools", @@ -1275,6 +1275,94 @@ ], "time": "2025-02-03T10:55:03+00:00" }, + { + "name": "laravel/cashier", + "version": "v15.6.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "8fe60cc71161ef06b6a1b23cffe886abf2a49b29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/8fe60cc71161ef06b6a1b23cffe886abf2a49b29", + "reference": "8fe60cc71161ef06b6a1b23cffe886abf2a49b29", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/log": "^10.0|^11.0|^12.0", + "illuminate/notifications": "^10.0|^11.0|^12.0", + "illuminate/pagination": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/view": "^10.0|^11.0|^12.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^16.2", + "symfony/console": "^6.0|^7.0", + "symfony/http-kernel": "^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.22.1" + }, + "require-dev": { + "dompdf/dompdf": "^2.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^8.18|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.4|^11.5" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^1.0.1|^2.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "15.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2025-04-22T13:59:36+00:00" + }, { "name": "laravel/framework", "version": "v10.48.29", @@ -2110,6 +2198,172 @@ ], "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": "moneyphp/money", + "version": "v4.7.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "af048f0206d3b39b8fad9de6a230cedf765365fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/af048f0206d3b39b8fad9de6a230cedf765365fa", + "reference": "af048f0206d3b39b8fad9de6a230cedf765365fa", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.7.0" + }, + "time": "2025-04-03T08:26:36+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -3701,6 +3955,65 @@ ], "time": "2024-12-02T08:40:45+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v16.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.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/v16.6.0" + }, + "time": "2025-02-24T22:35:29+00:00" + }, { "name": "symfony/console", "version": "v6.4.20", @@ -4977,6 +5290,90 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78", + "reference": "d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.31.0", From 2f5106a4411eda9312a50aca1334c9c915b75d69 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 10:57:23 -0400 Subject: [PATCH 31/37] refactor to 1 anystack product for .env --- config/subscriptions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/subscriptions.php b/config/subscriptions.php index ea5c16ad..ecb46c8b 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -6,21 +6,21 @@ '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_product_id' => env('ANYSTACK_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_MINI_POLICY_ID'), ], \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_product_id' => env('ANYSTACK_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_PRO_POLICY_ID'), ], \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'), - 'anystack_product_id' => env('ANYSTACK_MAX_PRODUCT_ID'), + 'anystack_product_id' => env('ANYSTACK_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_MAX_POLICY_ID'), ], ], From 9b1610ca7453f76cda5cf0374103aa0c3f6e0219 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 11:47:39 -0400 Subject: [PATCH 32/37] refactor session handling --- app/Livewire/OrderSuccess.php | 17 +++++++++++------ tests/Feature/Livewire/OrderSuccessTest.php | 8 ++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php index 70ed2034..e7f790fc 100644 --- a/app/Livewire/OrderSuccess.php +++ b/app/Livewire/OrderSuccess.php @@ -37,7 +37,7 @@ public function loadData(): void private function loadEmail(): ?string { - if ($email = session('customer_email')) { + if ($email = session($this->sessionKey('email'))) { return $email; } @@ -48,14 +48,14 @@ private function loadEmail(): ?string return null; } - session()->put('customer_email', $email); + session()->put($this->sessionKey('email'), $email); return $email; } private function loadLicenseKey(): ?string { - if ($licenseKey = session('license_key')) { + if ($licenseKey = session($this->sessionKey('license_key'))) { return $licenseKey; } @@ -64,7 +64,7 @@ private function loadLicenseKey(): ?string } if ($licenseKey = Cache::get($this->email.'.license_key')) { - session()->put('license_key', $licenseKey); + session()->put($this->sessionKey('license_key'), $licenseKey); } return $licenseKey; @@ -72,7 +72,7 @@ private function loadLicenseKey(): ?string private function loadSubscription(): ?Subscription { - if ($subscription = session('subscription')) { + if ($subscription = session($this->sessionKey('subscription'))) { return Subscription::tryFrom($subscription); } @@ -85,8 +85,13 @@ private function loadSubscription(): ?Subscription $subscription = Subscription::fromStripePriceId($priceId); - session()->put('subscription', $subscription->value); + session()->put($this->sessionKey('subscription'), $subscription->value); return $subscription; } + + private function sessionKey(string $key): string + { + return "{$this->checkoutSessionId}.{$key}"; + } } diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php index 49517bcc..5092dd4b 100644 --- a/tests/Feature/Livewire/OrderSuccessTest.php +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -61,8 +61,12 @@ public function it_displays_license_key_when_available() #[Test] public function it_uses_session_data_when_available() { - Session::put('customer_email', 'session@example.com'); - Session::put('license_key', 'session-license-key'); + Cache::flush(); + + $checkoutSessionId = 'cs_test_123'; + + Session::put("$checkoutSessionId.email", 'session@example.com'); + Session::put("$checkoutSessionId.license_key", 'session-license-key'); Livewire::test(OrderSuccess::class, ['checkoutSessionId' => 'cs_test_123']) ->assertSet('email', 'session@example.com') From f27f0b8496a129521ffa24ba57e47096ec166843 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 11:51:48 -0400 Subject: [PATCH 33/37] update .env.example --- .env.example | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 0d200e49..6b16085e 100644 --- a/.env.example +++ b/.env.example @@ -70,9 +70,7 @@ STRIPE_PRO_PAYMENT_LINK= STRIPE_MAX_PAYMENT_LINK= ANYSTACK_API_KEY= -ANYSTACK_MINI_PRODUCT_ID= -ANYSTACK_PRO_PRODUCT_ID= -ANYSTACK_MAX_PRODUCT_ID= +ANYSTACK_PRODUCT_ID= ANYSTACK_MINI_POLICY_ID= ANYSTACK_PRO_POLICY_ID= ANYSTACK_MAX_POLICY_ID= From dee3e0975101ffb29f0559d59f6316e1645377b4 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 11:57:57 -0400 Subject: [PATCH 34/37] use Subscription enum in view --- resources/views/components/mobile-pricing.blade.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/views/components/mobile-pricing.blade.php b/resources/views/components/mobile-pricing.blade.php index 1716500e..7b050437 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 2152d53368c77dd09e23b6e74bb9e251a1f1b0ae Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 11:58:28 -0400 Subject: [PATCH 35/37] remove unnecessary stripe service config & binding --- app/Providers/AppServiceProvider.php | 5 +---- config/services.php | 8 -------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b0f12aae..f58138ee 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,7 +5,6 @@ use App\Support\GitHub; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; -use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider { @@ -14,9 +13,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->bind(StripeClient::class, function () { - return new StripeClient(config('services.stripe.secret')); - }); + // } /** diff --git a/config/services.php b/config/services.php index 98124ac9..2b73a53f 100644 --- a/config/services.php +++ b/config/services.php @@ -31,14 +31,6 @@ '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'), ], From dc02f927c5e7b5f30538c60d8c8a826a2153654d Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 11:58:45 -0400 Subject: [PATCH 36/37] fix down method --- database/migrations/2025_04_30_135437_alter_users_table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2025_04_30_135437_alter_users_table.php b/database/migrations/2025_04_30_135437_alter_users_table.php index b4bf055f..752db699 100644 --- a/database/migrations/2025_04_30_135437_alter_users_table.php +++ b/database/migrations/2025_04_30_135437_alter_users_table.php @@ -16,7 +16,7 @@ public function up(): void public function down(): void { Schema::table('users', function (Blueprint $table) { - $table->string('name')->nullable(); + $table->string('name')->change(); }); } }; From 1848663c70156c34c36b5b8b5c195348231c2067 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Thu, 1 May 2025 12:10:14 -0400 Subject: [PATCH 37/37] use Cashier::stripe() to resolve StripeClient --- app/Livewire/OrderSuccess.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php index e7f790fc..6a49fb35 100644 --- a/app/Livewire/OrderSuccess.php +++ b/app/Livewire/OrderSuccess.php @@ -4,10 +4,10 @@ use App\Enums\Subscription; use Illuminate\Support\Facades\Cache; +use Laravel\Cashier\Cashier; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; -use Stripe\StripeClient; #[Layout('components.layout')] #[Title('Thank You for Your Purchase')] @@ -41,7 +41,7 @@ private function loadEmail(): ?string return $email; } - $stripe = app(StripeClient::class); + $stripe = Cashier::stripe(); $checkoutSession = $stripe->checkout->sessions->retrieve($this->checkoutSessionId); if (! ($email = $checkoutSession?->customer_details?->email)) { @@ -76,7 +76,7 @@ private function loadSubscription(): ?Subscription return Subscription::tryFrom($subscription); } - $stripe = app(StripeClient::class); + $stripe = Cashier::stripe(); $priceId = $stripe->checkout->sessions->allLineItems($this->checkoutSessionId)->first()?->price->id; if (! $priceId) {