Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/Jobs/HandleInvoicePaidJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
55 changes: 55 additions & 0 deletions app/Notifications/PurchaseReceipt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Notifications;

use App\Contracts\TransactionalNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class PurchaseReceipt extends Notification implements ShouldQueue, TransactionalNotification
{
use Queueable;

/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
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.',
];
}
}
55 changes: 55 additions & 0 deletions app/Notifications/UltraSubscriptionStarted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Notifications;

use App\Contracts\TransactionalNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class UltraSubscriptionStarted extends Notification implements ShouldQueue, TransactionalNotification
{
use Queueable;

/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
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.',
];
}
}
87 changes: 87 additions & 0 deletions tests/Feature/Jobs/HandleInvoicePaidJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [
Expand Down
Loading