diff --git a/.env.example b/.env.example index 6886b6d1..6b16085e 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,19 @@ 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_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..e7e0c0ae --- /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: 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() + ->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/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/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php new file mode 100644 index 00000000..78941ef4 --- /dev/null +++ b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php @@ -0,0 +1,56 @@ +constructStripeSubscription(); + + if (! $stripeSubscription) { + $this->fail('The Stripe webhook payload could not be constructed into a Stripe Subscription object.'); + + return; + } + + $user = Cashier::findBillable($stripeSubscription->customer); + + if (! $user || ! ($email = $user->email)) { + $this->fail('Failed to find user from Stripe subscription customer.'); + + return; + } + + $subscriptionPlan = \App\Enums\Subscription::fromStripeSubscription($stripeSubscription); + + $nameParts = explode(' ', $user->name ?? '', 2); + $firstName = $nameParts[0] ?: null; + $lastName = $nameParts[1] ?? null; + + dispatch(new CreateAnystackLicenseJob( + $email, + $subscriptionPlan, + $firstName, + $lastName, + )); + } + + protected function constructStripeSubscription(): ?Subscription + { + 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/Livewire/OrderSuccess.php b/app/Livewire/OrderSuccess.php new file mode 100644 index 00000000..6a49fb35 --- /dev/null +++ b/app/Livewire/OrderSuccess.php @@ -0,0 +1,97 @@ +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($this->sessionKey('email'))) { + return $email; + } + + $stripe = Cashier::stripe(); + $checkoutSession = $stripe->checkout->sessions->retrieve($this->checkoutSessionId); + + if (! ($email = $checkoutSession?->customer_details?->email)) { + return null; + } + + session()->put($this->sessionKey('email'), $email); + + return $email; + } + + private function loadLicenseKey(): ?string + { + if ($licenseKey = session($this->sessionKey('license_key'))) { + return $licenseKey; + } + + if (! $this->email) { + return null; + } + + if ($licenseKey = Cache::get($this->email.'.license_key')) { + session()->put($this->sessionKey('license_key'), $licenseKey); + } + + return $licenseKey; + } + + private function loadSubscription(): ?Subscription + { + if ($subscription = session($this->sessionKey('subscription'))) { + return Subscription::tryFrom($subscription); + } + + $stripe = Cashier::stripe(); + $priceId = $stripe->checkout->sessions->allLineItems($this->checkoutSessionId)->first()?->price->id; + + if (! $priceId) { + return null; + } + + $subscription = Subscription::fromStripePriceId($priceId); + + session()->put($this->sessionKey('subscription'), $subscription->value); + + return $subscription; + } + + private function sessionKey(string $key): string + { + return "{$this->checkoutSessionId}.{$key}"; + } +} 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/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/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 2c5fa4b7..216ab06e 100644 --- a/composer.json +++ b/composer.json @@ -2,17 +2,22 @@ "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", "sentry/sentry-laravel": "^4.13", "spatie/laravel-menu": "^4.1", "spatie/yaml-front-matter": "^2.0", diff --git a/composer.lock b/composer.lock index 6e9075e3..53721ae1 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": "8192bed76076a0833a257911044cb42d", + "content-hash": "faf4963b710207abf9e63f6bb67038c8", "packages": [ { "name": "artesaos/seotools", @@ -82,12 +82,12 @@ "version": "2.6.0", "source": { "type": "git", - "url": "https://github.com/blade-ui-kit/blade-heroicons.git", + "url": "https://github.com/driesvints/blade-heroicons.git", "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/blade-ui-kit/blade-heroicons/zipball/4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", "shasum": "" }, @@ -131,8 +131,8 @@ "laravel" ], "support": { - "issues": "https://github.com/blade-ui-kit/blade-heroicons/issues", - "source": "https://github.com/blade-ui-kit/blade-heroicons/tree/2.6.0" + "issues": "https://github.com/driesvints/blade-heroicons/issues", + "source": "https://github.com/driesvints/blade-heroicons/tree/2.6.0" }, "funding": [ { @@ -151,12 +151,12 @@ "version": "1.8.0", "source": { "type": "git", - "url": "https://github.com/blade-ui-kit/blade-icons.git", + "url": "https://github.com/driesvints/blade-icons.git", "reference": "7b743f27476acb2ed04cb518213d78abe096e814" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/blade-ui-kit/blade-icons/zipball/7b743f27476acb2ed04cb518213d78abe096e814", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/7b743f27476acb2ed04cb518213d78abe096e814", "reference": "7b743f27476acb2ed04cb518213d78abe096e814", "shasum": "" }, @@ -1335,6 +1335,94 @@ }, "time": "2025-03-19T14:43:43+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", @@ -1795,16 +1883,16 @@ }, { "name": "league/commonmark", - "version": "2.6.1", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "d990688c91cedfb69753ffc2512727ec646df2ad" + "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad", - "reference": "d990688c91cedfb69753ffc2512727ec646df2ad", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/06c3b0bf2540338094575612f4a1778d0d2d5e94", + "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94", "shasum": "" }, "require": { @@ -1898,7 +1986,7 @@ "type": "tidelift" } ], - "time": "2024-12-29T14:10:59+00:00" + "time": "2025-04-18T21:09:27+00:00" }, { "name": "league/config", @@ -2170,6 +2258,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", @@ -4017,18 +4271,77 @@ ], "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", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36" + "reference": "a3011c7b7adb58d89f6c0d822abb641d7a5f9719" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2e4af9c952617cc3f9559ff706aee420a8464c36", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36", + "url": "https://api.github.com/repos/symfony/console/zipball/a3011c7b7adb58d89f6c0d822abb641d7a5f9719", + "reference": "a3011c7b7adb58d89f6c0d822abb641d7a5f9719", "shasum": "" }, "require": { @@ -4093,7 +4406,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.20" + "source": "https://github.com/symfony/console/tree/v6.4.21" }, "funding": [ { @@ -4109,7 +4422,7 @@ "type": "tidelift" } ], - "time": "2025-03-03T17:16:38+00:00" + "time": "2025-04-07T15:42:41+00:00" }, { "name": "symfony/css-selector", @@ -4713,16 +5026,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.18", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "d0492d6217e5ab48f51fca76f64cf8e78919d0db" + "reference": "3f0c7ea41db479383b81d436b836d37168fd5b99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d0492d6217e5ab48f51fca76f64cf8e78919d0db", - "reference": "d0492d6217e5ab48f51fca76f64cf8e78919d0db", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/3f0c7ea41db479383b81d436b836d37168fd5b99", + "reference": "3f0c7ea41db479383b81d436b836d37168fd5b99", "shasum": "" }, "require": { @@ -4770,7 +5083,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.18" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.21" }, "funding": [ { @@ -4786,20 +5099,20 @@ "type": "tidelift" } ], - "time": "2025-01-09T15:48:56+00:00" + "time": "2025-04-27T13:27:38+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.20", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6be6db31bc74693ce5516e1fd5e5ff1171005e37" + "reference": "983ca05eec6623920d24ec0f1005f487d3734a0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6be6db31bc74693ce5516e1fd5e5ff1171005e37", - "reference": "6be6db31bc74693ce5516e1fd5e5ff1171005e37", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/983ca05eec6623920d24ec0f1005f487d3734a0c", + "reference": "983ca05eec6623920d24ec0f1005f487d3734a0c", "shasum": "" }, "require": { @@ -4884,7 +5197,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.20" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.21" }, "funding": [ { @@ -4900,20 +5213,20 @@ "type": "tidelift" } ], - "time": "2025-03-28T13:27:10+00:00" + "time": "2025-05-02T08:46:38+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.18", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e93a6ae2767d7f7578c2b7961d9d8e27580b2b11" + "reference": "ada2809ccd4ec27aba9fc344e3efdaec624c6438" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e93a6ae2767d7f7578c2b7961d9d8e27580b2b11", - "reference": "e93a6ae2767d7f7578c2b7961d9d8e27580b2b11", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ada2809ccd4ec27aba9fc344e3efdaec624c6438", + "reference": "ada2809ccd4ec27aba9fc344e3efdaec624c6438", "shasum": "" }, "require": { @@ -4964,7 +5277,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.18" + "source": "https://github.com/symfony/mailer/tree/v6.4.21" }, "funding": [ { @@ -4980,7 +5293,7 @@ "type": "tidelift" } ], - "time": "2025-01-24T15:27:15+00:00" + "time": "2025-04-26T23:47:35+00:00" }, { "name": "symfony/mailgun-mailer", @@ -5053,16 +5366,16 @@ }, { "name": "symfony/mime", - "version": "v6.4.19", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3" + "reference": "fec8aa5231f3904754955fad33c2db50594d22d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3", - "reference": "ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3", + "url": "https://api.github.com/repos/symfony/mime/zipball/fec8aa5231f3904754955fad33c2db50594d22d1", + "reference": "fec8aa5231f3904754955fad33c2db50594d22d1", "shasum": "" }, "require": { @@ -5118,7 +5431,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.19" + "source": "https://github.com/symfony/mime/tree/v6.4.21" }, "funding": [ { @@ -5134,7 +5447,7 @@ "type": "tidelift" } ], - "time": "2025-02-17T21:23:52+00:00" + "time": "2025-04-27T13:27:38+00:00" }, { "name": "symfony/options-resolver", @@ -5284,7 +5597,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -5342,7 +5655,91 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.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-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": [ { @@ -5445,7 +5842,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5506,7 +5903,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -5526,19 +5923,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5586,7 +5984,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -5602,20 +6000,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -5666,7 +6064,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -5682,11 +6080,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -5742,7 +6140,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -5762,7 +6160,7 @@ }, { "name": "symfony/polyfill-uuid", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -5821,7 +6219,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" }, "funding": [ { @@ -6151,16 +6549,16 @@ }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", "shasum": "" }, "require": { @@ -6218,7 +6616,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.2.6" }, "funding": [ { @@ -6234,20 +6632,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:18:16+00:00" }, { "name": "symfony/translation", - "version": "v6.4.19", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "3b9bf9f33997c064885a7bfc126c14b9daa0e00e" + "reference": "bb92ea5588396b319ba43283a5a3087a034cb29c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/3b9bf9f33997c064885a7bfc126c14b9daa0e00e", - "reference": "3b9bf9f33997c064885a7bfc126c14b9daa0e00e", + "url": "https://api.github.com/repos/symfony/translation/zipball/bb92ea5588396b319ba43283a5a3087a034cb29c", + "reference": "bb92ea5588396b319ba43283a5a3087a034cb29c", "shasum": "" }, "require": { @@ -6313,7 +6711,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.19" + "source": "https://github.com/symfony/translation/tree/v6.4.21" }, "funding": [ { @@ -6329,7 +6727,7 @@ "type": "tidelift" } ], - "time": "2025-02-13T10:18:43+00:00" + "time": "2025-04-07T19:02:30+00:00" }, { "name": "symfony/translation-contracts", @@ -6485,16 +6883,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.18", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "4ad10cf8b020e77ba665305bb7804389884b4837" + "reference": "22560f80c0c5cd58cc0bcaf73455ffd81eb380d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/4ad10cf8b020e77ba665305bb7804389884b4837", - "reference": "4ad10cf8b020e77ba665305bb7804389884b4837", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/22560f80c0c5cd58cc0bcaf73455ffd81eb380d5", + "reference": "22560f80c0c5cd58cc0bcaf73455ffd81eb380d5", "shasum": "" }, "require": { @@ -6550,7 +6948,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.18" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.21" }, "funding": [ { @@ -6566,20 +6964,20 @@ "type": "tidelift" } ], - "time": "2025-01-17T11:26:11+00:00" + "time": "2025-04-09T07:34:50+00:00" }, { "name": "symfony/yaml", - "version": "v7.2.5", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912" + "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912", - "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0feafffb843860624ddfd13478f481f4c3cd8b23", + "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23", "shasum": "" }, "require": { @@ -6622,7 +7020,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.5" + "source": "https://github.com/symfony/yaml/tree/v7.2.6" }, "funding": [ { @@ -6638,7 +7036,7 @@ "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-04-04T10:10:11+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6817,16 +7215,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { @@ -6885,7 +7283,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -6897,7 +7295,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:52:34+00:00" + "time": "2025-04-30T23:37:27+00:00" }, { "name": "voku/portable-ascii", @@ -7169,20 +7567,20 @@ }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -7190,8 +7588,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -7214,22 +7612,22 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { "name": "laravel/pint", - "version": "v1.21.2", + "version": "v1.22.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "370772e7d9e9da087678a0edf2b11b6960e40558" + "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/370772e7d9e9da087678a0edf2b11b6960e40558", - "reference": "370772e7d9e9da087678a0edf2b11b6960e40558", + "url": "https://api.github.com/repos/laravel/pint/zipball/7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", + "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", "shasum": "" }, "require": { @@ -7240,9 +7638,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.72.0", + "friendsofphp/php-cs-fixer": "^3.75.0", "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.2.0", + "larastan/larastan": "^3.3.1", "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3", @@ -7282,20 +7680,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-03-14T22:31:42+00:00" + "time": "2025-04-08T22:11:45+00:00" }, { "name": "laravel/sail", - "version": "v1.41.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "fe1a4ada0abb5e4bd99eb4e4b0d87906c00cdeec" + "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/fe1a4ada0abb5e4bd99eb4e4b0d87906c00cdeec", - "reference": "fe1a4ada0abb5e4bd99eb4e4b0d87906c00cdeec", + "url": "https://api.github.com/repos/laravel/sail/zipball/2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", + "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", "shasum": "" }, "require": { @@ -7345,7 +7743,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-01-24T15:45:36+00:00" + "time": "2025-04-29T14:26:46+00:00" }, { "name": "mockery/mockery", @@ -7432,16 +7830,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -7480,7 +7878,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -7488,7 +7886,7 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "nunomaduro/collision", @@ -8027,16 +8425,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.45", + "version": "10.5.46", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "bd68a781d8e30348bc297449f5234b3458267ae8" + "reference": "8080be387a5be380dda48c6f41cee4a13aadab3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bd68a781d8e30348bc297449f5234b3458267ae8", - "reference": "bd68a781d8e30348bc297449f5234b3458267ae8", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8080be387a5be380dda48c6f41cee4a13aadab3d", + "reference": "8080be387a5be380dda48c6f41cee4a13aadab3d", "shasum": "" }, "require": { @@ -8046,7 +8444,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -8108,7 +8506,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.45" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.46" }, "funding": [ { @@ -8119,12 +8517,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-02-06T16:08:12+00:00" + "time": "2025-05-02T06:46:24+00:00" }, { "name": "sebastian/cli-parser", @@ -9044,16 +9450,16 @@ }, { "name": "spatie/backtrace", - "version": "1.7.1", + "version": "1.7.2", "source": { "type": "git", "url": "https://github.com/spatie/backtrace.git", - "reference": "0f2477c520e3729de58e061b8192f161c99f770b" + "reference": "9807de6b8fecfaa5b3d10650985f0348b02862b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/backtrace/zipball/0f2477c520e3729de58e061b8192f161c99f770b", - "reference": "0f2477c520e3729de58e061b8192f161c99f770b", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/9807de6b8fecfaa5b3d10650985f0348b02862b2", + "reference": "9807de6b8fecfaa5b3d10650985f0348b02862b2", "shasum": "" }, "require": { @@ -9091,7 +9497,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/backtrace/tree/1.7.1" + "source": "https://github.com/spatie/backtrace/tree/1.7.2" }, "funding": [ { @@ -9103,7 +9509,7 @@ "type": "other" } ], - "time": "2024-12-02T13:28:15+00:00" + "time": "2025-04-28T14:55:53+00:00" }, { "name": "spatie/error-solutions", 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/services.php b/config/services.php index 0ace530e..2b73a53f 100644 --- a/config/services.php +++ b/config/services.php @@ -31,4 +31,7 @@ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'anystack' => [ + 'key' => env('ANYSTACK_API_KEY'), + ], ]; diff --git a/config/subscriptions.php b/config/subscriptions.php new file mode 100644 index 00000000..ecb46c8b --- /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_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_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_PRODUCT_ID'), + 'anystack_policy_id' => env('ANYSTACK_MAX_POLICY_ID'), + ], + ], +]; 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_30_135437_alter_users_table.php b/database/migrations/2025_04_30_135437_alter_users_table.php new file mode 100644 index 00000000..752db699 --- /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')->change(); + }); + } +}; 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..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 --}} 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. 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 1ceb626b..0471a388 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,3 +52,5 @@ return redirect("/docs/{$version}/{$page}"); })->name('docs')->where('page', '.*'); + +Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); 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/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 new file mode 100644 index 00000000..2b1ba3c6 --- /dev/null +++ b/tests/Feature/Jobs/HandleCustomerSubscriptionCreatedJobTest.php @@ -0,0 +1,316 @@ + 'cus_S9dhoV2rJK2Auy', + 'email' => 'test@example.com', + 'name' => 'John Doe', + ]); + + $this->mockStripeClient($mockCustomer); + + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + Bus::fake(); + + $webhookCall = new WebhookHandled($this->getTestWebhookPayload()); + + $job = new HandleCustomerSubscriptionCreatedJob($webhookCall); + $job->handle(); + + 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) + { + $mockCustomer = Customer::constructFrom([ + 'id' => 'cus_S9dhoV2rJK2Auy', + 'email' => 'test@example.com', + 'name' => $fullName, + ]); + + $this->mockStripeClient($mockCustomer); + + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + $webhookCall = new WebhookHandled($this->getTestWebhookPayload()); + + Bus::fake(); + + $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() + { + $mockCustomer = Customer::constructFrom([ + 'id' => 'cus_S9dhoV2rJK2Auy', + 'email' => '', + 'name' => 'John Doe', + ]); + + $this->mockStripeClient($mockCustomer); + + dispatch_sync(new CreateUserFromStripeCustomer($mockCustomer)); + + Bus::fake(); + + $webhookCall = new WebhookHandled($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..5092dd4b --- /dev/null +++ b/tests/Feature/Livewire/OrderSuccessTest.php @@ -0,0 +1,158 @@ +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() + { + 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') + ->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/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/StripeWebhookRouteTest.php b/tests/Feature/StripeWebhookRouteTest.php new file mode 100644 index 00000000..9617b846 --- /dev/null +++ b/tests/Feature/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); + } +}