diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index a7d64dad..4543238e 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -16,6 +16,8 @@ use App\Models\ProductLicense; use App\Models\User; use App\Notifications\PluginSaleCompleted; +use App\Notifications\PurchaseReceipt; +use App\Notifications\UltraSubscriptionStarted; use App\Support\GitHubOAuth; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -26,6 +28,7 @@ use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Laravel\Cashier\SubscriptionItem; +use RuntimeException; use Stripe\Invoice; use Stripe\StripeObject; use UnexpectedValueException; @@ -66,6 +69,8 @@ private function handleSubscriptionUpdate(): void private function handleSubscriptionCreated(): void { + $this->notifyUltraSubscriber(); + // Get the subscription to check for renewal metadata $subscription = Cashier::stripe()->subscriptions->retrieve($this->invoice->subscription); @@ -211,6 +216,9 @@ private function handleManualInvoice(): void // Notify developers of their sales $this->sendDeveloperSaleNotifications($this->invoice->id); + + // Thank the buyer for their purchase + $user->notify(new PurchaseReceipt); } private function processCartPurchase(string $cartId): void @@ -277,6 +285,9 @@ private function processCartPurchase(string $cartId): void // Notify developers of their sales $this->sendDeveloperSaleNotifications($this->invoice->id); + // Thank the buyer for their purchase + $user->notify(new PurchaseReceipt); + Log::info('Cart purchase completed', [ 'invoice_id' => $this->invoice->id, 'cart_id' => $cartId, @@ -649,6 +660,27 @@ private function sendDeveloperSaleNotifications(string $invoiceId): void }); } + private function notifyUltraSubscriber(): void + { + $line = $this->findPlanLineItem(); + + if (! $line || ! $line->price) { + return; + } + + try { + $plan = Subscription::fromStripePriceId($line->price->id); + } catch (RuntimeException) { + return; + } + + if ($plan !== Subscription::Max) { + return; + } + + $this->billable()->notify(new UltraSubscriptionStarted); + } + private function billable(): User { if ($user = Cashier::findBillable($this->invoice->customer)) { diff --git a/app/Notifications/PurchaseReceipt.php b/app/Notifications/PurchaseReceipt.php new file mode 100644 index 00000000..167c7737 --- /dev/null +++ b/app/Notifications/PurchaseReceipt.php @@ -0,0 +1,55 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $greeting = $notifiable->first_name + ? "Thank you, {$notifiable->first_name}!" + : 'Thank you for your purchase!'; + + return (new MailMessage) + ->subject('Thank you for your purchase') + ->greeting($greeting) + ->line('Your order is complete. Thank you for supporting NativePHP — every purchase helps fund the open source projects the ecosystem is built on.') + ->line('A receipt for this payment will arrive separately from Stripe, our payment processor.') + ->line('You can review your order history and access everything you have purchased from your dashboard.') + ->action('View Your Dashboard', route('customer.purchase-history.index')) + ->line("If you have any questions, just reply to this email and we'll be happy to help.") + ->salutation("Happy coding!\n\nThe NativePHP Team") + ->success(); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'Thank you for your purchase', + 'body' => 'Your order is complete. You can access your purchases from your dashboard.', + ]; + } +} diff --git a/app/Notifications/UltraSubscriptionStarted.php b/app/Notifications/UltraSubscriptionStarted.php new file mode 100644 index 00000000..db848075 --- /dev/null +++ b/app/Notifications/UltraSubscriptionStarted.php @@ -0,0 +1,55 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $greeting = $notifiable->first_name + ? "Welcome to Ultra, {$notifiable->first_name}!" + : 'Welcome to Ultra!'; + + return (new MailMessage) + ->subject('Welcome to NativePHP Ultra') + ->greeting($greeting) + ->line('Thank you for subscribing to Ultra. Your support directly funds NativePHP and the open source projects the ecosystem is built on.') + ->line('A receipt for this payment will arrive separately from Stripe, our payment processor.') + ->line('You can manage your subscription and explore everything your Ultra membership unlocks from your Ultra dashboard.') + ->action('Go to Your Ultra Dashboard', route('customer.ultra.index')) + ->line("If you have any questions, just reply to this email and we'll be happy to help.") + ->salutation("Happy coding!\n\nThe NativePHP Team") + ->success(); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'Welcome to NativePHP Ultra', + 'body' => 'Your Ultra subscription is active. Explore your benefits from the Ultra dashboard.', + ]; + } +} diff --git a/tests/Feature/Jobs/HandleInvoicePaidJobTest.php b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php index 6bc2c3ea..8fefc8f6 100644 --- a/tests/Feature/Jobs/HandleInvoicePaidJobTest.php +++ b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php @@ -4,9 +4,15 @@ use App\Jobs\CreateAnystackLicenseJob; use App\Jobs\HandleInvoicePaidJob; +use App\Models\Cart; +use App\Models\CartItem; +use App\Models\Plugin; use App\Models\User; +use App\Notifications\PurchaseReceipt; +use App\Notifications\UltraSubscriptionStarted; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Notification; use Laravel\Cashier\SubscriptionItem; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -116,6 +122,87 @@ public function it_does_not_auto_set_is_comped_when_invoice_total_is_zero(): voi $this->assertEquals(0, $subscription->price_paid); } + #[Test] + public function it_sends_ultra_welcome_email_when_an_ultra_subscription_is_created(): void + { + Notification::fake(); + + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $priceId = 'price_test_max'; + config(['subscriptions.plans.max.stripe_price_id' => $priceId]); + + $this->mockStripeSubscriptionRetrieve('sub_test123'); + + $invoice = $this->createStripeInvoice( + customerId: 'cus_test123', + subscriptionId: 'sub_test123', + billingReason: Invoice::BILLING_REASON_SUBSCRIPTION_CREATE, + priceId: $priceId, + subscriptionItemId: 'si_test123', + ); + + (new HandleInvoicePaidJob($invoice))->handle(); + + Notification::assertSentTo($user, UltraSubscriptionStarted::class); + } + + #[Test] + public function it_does_not_send_ultra_welcome_email_for_non_ultra_subscriptions(): void + { + Notification::fake(); + + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $priceId = 'price_test_pro'; + config(['subscriptions.plans.pro.stripe_price_id' => $priceId]); + + $this->mockStripeSubscriptionRetrieve('sub_test123'); + + $invoice = $this->createStripeInvoice( + customerId: 'cus_test123', + subscriptionId: 'sub_test123', + billingReason: Invoice::BILLING_REASON_SUBSCRIPTION_CREATE, + priceId: $priceId, + subscriptionItemId: 'si_test123', + ); + + (new HandleInvoicePaidJob($invoice))->handle(); + + Notification::assertNothingSentTo($user); + } + + #[Test] + public function it_sends_a_purchase_receipt_to_the_buyer_after_a_cart_purchase(): void + { + Notification::fake(); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']); + + $plugin = Plugin::factory()->approved()->free()->create(['is_active' => true]); + + $cart = Cart::factory()->for($buyer)->create(); + CartItem::create([ + 'cart_id' => $cart->id, + 'plugin_id' => $plugin->id, + 'price_at_addition' => 0, + ]); + + $invoice = Invoice::constructFrom([ + 'id' => 'in_test_'.uniqid(), + 'billing_reason' => Invoice::BILLING_REASON_MANUAL, + 'customer' => $buyer->stripe_id, + 'payment_intent' => 'pi_test_'.uniqid(), + 'currency' => 'usd', + 'metadata' => ['cart_id' => $cart->id], + 'lines' => [], + ]); + + (new HandleInvoicePaidJob($invoice))->handle(); + + Notification::assertSentTo($buyer, PurchaseReceipt::class); + } + public static function subscriptionPlanProvider(): array { return [