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/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/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/Jobs/CreateAnystackLicenseJob.php b/app/Jobs/CreateAnystackLicenseJob.php new file mode 100644 index 00000000..5f902ac7 --- /dev/null +++ b/app/Jobs/CreateAnystackLicenseJob.php @@ -0,0 +1,81 @@ +createContact(); + + $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->subscription, + $this->firstName + )); + } + + 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->subscription->anystackPolicyId(), + 'contact_id' => $contactId, + ]; + + return $this->anystackClient() + ->post("https://api.anystack.sh/v1/products/{$this->subscription->anystackProductId()}/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..dc604314 --- /dev/null +++ b/app/Jobs/StripeWebhooks/HandleCustomerSubscriptionCreatedJob.php @@ -0,0 +1,60 @@ +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; + } + + $subscriptionPlan = \App\Enums\Subscription::fromStripeSubscription($stripeSubscription); + + $nameParts = explode(' ', $customer->name ?? '', 2); + $firstName = $nameParts[0] ?: null; + $lastName = $nameParts[1] ?? null; + + dispatch(new CreateAnystackLicenseJob( + $email, + $subscriptionPlan, + $firstName, + $lastName, + )); + } + + protected function constructStripeSubscription(): ?Subscription + { + return Event::constructFrom($this->webhook->payload)->data?->object; + } +} diff --git a/app/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php new file mode 100644 index 00000000..70ed2034 --- /dev/null +++ b/app/Livewire/OrderSuccess.php @@ -0,0 +1,92 @@ +checkoutSessionId = $checkoutSessionId; + + $this->loadData(); + } + + public function loadData(): void + { + $this->email = $this->loadEmail(); + $this->licenseKey = $this->loadLicenseKey(); + $this->subscription = $this->loadSubscription(); + } + + 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; + } + + 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 new file mode 100644 index 00000000..7ece8ecb --- /dev/null +++ b/app/Notifications/LicenseKeyGenerated.php @@ -0,0 +1,63 @@ + + */ + 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.") + ->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(); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'license_key' => $this->licenseKey, + 'firstName' => $this->firstName, + ]; + } +} 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..269a702e 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,11 @@ "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 7c84117b..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": "d122544fddb1584d07dc227cdfb97593", + "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", @@ -3462,6 +3538,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 +3980,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/config/subscriptions.php b/config/subscriptions.php new file mode 100644 index 00000000..ea5c16ad --- /dev/null +++ b/config/subscriptions.php @@ -0,0 +1,27 @@ + [ + \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'), + ], + \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'), + ], + \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_policy_id' => env('ANYSTACK_MAX_POLICY_ID'), + ], + ], +]; 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..db97aceb --- /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/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') diff --git a/resources/views/components/mobile-pricing.blade.php b/resources/views/components/mobile-pricing.blade.php index 4dfca702..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 --}} diff --git a/resources/views/livewire/order-success.blade.php b/resources/views/livewire/order-success.blade.php new file mode 100644 index 00000000..222b4109 --- /dev/null +++ b/resources/views/livewire/order-success.blade.php @@ -0,0 +1,518 @@ +
+ {{-- Hero Section --}} +
+
+ {{-- Primary Heading --}} +

+ You're In! +

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

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

+
+ + {{-- Success Card --}} +
+ + {{-- Next Steps --}} + +
+
diff --git a/routes/web.php b/routes/web.php index a45a03c8..13cae0ac 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\Livewire\OrderSuccess::class)->name('order.success'); +Route::stripeWebhooks('stripe/webhook'); 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); - } -} diff --git a/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php new file mode 100644 index 00000000..05747d25 --- /dev/null +++ b/tests/Feature/Jobs/CreateAnystackLicenseJobTest.php @@ -0,0 +1,148 @@ + 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', + Subscription::Max, + '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', + ]; + }); + + $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' => Subscription::Max->anystackPolicyId(), + 'contact_id' => 'contact-123', + ]; + }); + } + + /** @test */ + public function it_stores_license_key_in_cache() + { + $job = new CreateAnystackLicenseJob( + 'test@example.com', + Subscription::Max, + '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', + Subscription::Max, + '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->subscription === Subscription::Max && + $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', + Subscription::Max, + ); + + $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->subscription === Subscription::Max && + $notification->firstName === null; + } + ); + } +} diff --git a/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php new file mode 100644 index 00000000..d0b1897c --- /dev/null +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -0,0 +1,325 @@ + '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 (CreateAnystackLicenseJob $job) { + return $job->email === 'test@example.com' && + $job->subscription === Subscription::Max && + $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 static 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' => Subscription::Max->stripePriceId(), + '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' => Subscription::Max->stripePriceId(), + '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' => Subscription::Max->stripePriceId(), + '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); + } +} diff --git a/tests/Feature/Livewire/OrderSuccessTest.php b/tests/Feature/Livewire/OrderSuccessTest.php new file mode 100644 index 00000000..49517bcc --- /dev/null +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -0,0 +1,154 @@ +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', + ], + ]); + + $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) + { + private $mockCheckoutSession; + + public function __construct($mockCheckoutSession) + { + $this->mockCheckoutSession = $mockCheckoutSession; + } + }; + + $mockStripeClient->checkout->sessions = new class($mockCheckoutSession, $mockCheckoutSessionLineItems) + { + private $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); + } +} 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); + } +} 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'] + ); + } +} 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); + } +}