diff --git a/app/Actions/RefundPluginPurchase.php b/app/Actions/RefundPluginPurchase.php index 10b9a500..8b5c7a94 100644 --- a/app/Actions/RefundPluginPurchase.php +++ b/app/Actions/RefundPluginPurchase.php @@ -7,6 +7,7 @@ use App\Services\StripeConnectService; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Stripe\LineItem; class RefundPluginPurchase { @@ -15,10 +16,15 @@ public function __construct(private StripeConnectService $stripeConnectService) /** * Refund a plugin purchase, revoking the license and cancelling/reversing the payout. * + * Only the license's own line of the Stripe checkout is refunded, at what the buyer paid + * for it after coupons and tax, so anything else bought in the same checkout is left alone. + * * For bundle purchases, all sibling licenses sharing the same stripe_payment_intent_id - * are refunded together. + * are refunded together. A bundle is one line of the checkout, so that is one refund. + * + * @return int The amount refunded, in cents. */ - public function handle(PluginLicense $license, User $refundedBy): void + public function handle(PluginLicense $license, User $refundedBy): int { if (! $license->isRefundable()) { throw new \RuntimeException('This license is not eligible for a refund.'); @@ -26,7 +32,9 @@ public function handle(PluginLicense $license, User $refundedBy): void $licenses = $this->collectLicensesToRefund($license); - $refund = $this->stripeConnectService->refundPaymentIntent($license->stripe_payment_intent_id); + $lineItem = $this->refundableLineItem($license); + + $refund = $this->stripeConnectService->refundCheckoutLineItem($license->stripe_payment_intent_id, $lineItem); DB::transaction(function () use ($licenses, $refund, $refundedBy): void { foreach ($licenses as $licenseToRefund) { @@ -49,6 +57,8 @@ public function handle(PluginLicense $license, User $refundedBy): void $payout->markAsCancelled(); } }); + + return $lineItem->amount_total; } /** @@ -65,4 +75,44 @@ private function collectLicensesToRefund(PluginLicense $license): Collection ->where('plugin_bundle_id', $license->plugin_bundle_id) ->get(); } + + /** + * The line of the license's Stripe checkout to refund, which holds what the buyer paid for it. + */ + private function refundableLineItem(PluginLicense $license): LineItem + { + $lineItem = $this->findCheckoutLineItem($license); + + if (! $lineItem) { + throw new \RuntimeException('Could not find this license on its Stripe checkout, so there is no amount to refund.'); + } + + if ($lineItem->amount_total <= 0) { + throw new \RuntimeException('Nothing was charged for this license, so there is nothing to refund.'); + } + + return $lineItem; + } + + /** + * Find the line of the license's Stripe checkout that it was bought on. + * + * Lines are tagged with the ID of the plugin or bundle they are for. Checkouts created + * before that tagging have no metadata, so those are matched on the name the checkout + * gave the line instead. A line that matches neither way is never used. + */ + private function findCheckoutLineItem(PluginLicense $license): ?LineItem + { + $lineItems = $this->stripeConnectService->checkoutLineItems($license->stripe_payment_intent_id); + + [$metadataKey, $id, $name] = $license->wasPurchasedAsBundle() + ? ['plugin_bundle_id', $license->plugin_bundle_id, $license->pluginBundle->name.' (Bundle)'] + : ['plugin_id', $license->plugin_id, $license->plugin->name]; + + $taggedLineItem = $lineItems->first( + fn (LineItem $lineItem): bool => (string) ($lineItem->price->product->metadata[$metadataKey] ?? '') === (string) $id + ); + + return $taggedLineItem ?? $lineItems->first(fn (LineItem $lineItem): bool => $lineItem->description === $name); + } } diff --git a/app/Filament/Actions/RefundPluginLicenseAction.php b/app/Filament/Actions/RefundPluginLicenseAction.php index a727aab2..acceba7a 100644 --- a/app/Filament/Actions/RefundPluginLicenseAction.php +++ b/app/Filament/Actions/RefundPluginLicenseAction.php @@ -25,8 +25,7 @@ protected function setUp(): void ->requiresConfirmation() ->modalHeading('Refund purchase') ->modalDescription(function (PluginLicense $record): string { - $amount = '$'.number_format($record->price_paid / 100, 2); - $description = "This will issue a full {$amount} refund to {$record->user->email} for {$record->plugin->name} and revoke their license."; + $description = "This will refund {$record->user->email} what they paid for {$record->plugin->name}, after any coupon or tax, and revoke their license."; if ($record->wasPurchasedAsBundle()) { $description .= ' This license was bought as part of a bundle, so every license in the bundle will be refunded.'; @@ -38,10 +37,11 @@ protected function setUp(): void ->visible(fn (PluginLicense $record): bool => $record->isRefundable()) ->action(function (PluginLicense $record): void { try { - app(RefundPluginPurchase::class)->handle($record, auth()->user()); + $amount = app(RefundPluginPurchase::class)->handle($record, auth()->user()); Notification::make() ->title('Purchase refunded successfully') + ->body('Refunded $'.number_format($amount / 100, 2).'.') ->success() ->send(); } catch (\Exception $e) { diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index 06b3b240..8849af50 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -482,6 +482,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session 'product_data' => [ 'name' => $bundle->name.' (Bundle)', 'description' => 'Includes: '.$pluginNames, + 'metadata' => ['plugin_bundle_id' => $bundle->id], ], ], 'quantity' => 1, @@ -525,6 +526,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session 'product_data' => [ 'name' => $plugin->name, 'description' => $plugin->description ?? 'NativePHP Plugin', + 'metadata' => ['plugin_id' => $plugin->id], ], ], 'quantity' => 1, diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index 896fc2c4..f58edc14 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -189,6 +189,13 @@ private function handleManualInvoice(): void return; } + // A product bought on its own, outside the cart (e.g. the Masterclass from /course) + if (! empty($metadata['product_id'])) { + $this->processProductPurchase($metadata['product_id']); + + return; + } + // Legacy: Only process if this is a plugin purchase (has plugin metadata) if (empty($metadata['plugin_ids']) && empty($metadata['bundle_ids'])) { return; @@ -299,6 +306,50 @@ private function processCartPurchase(string $cartId): void ]); } + private function processProductPurchase(string $productId): void + { + $product = Product::find($productId); + + if (! $product) { + Log::error('Product not found for invoice', [ + 'invoice_id' => $this->invoice->id, + 'product_id' => $productId, + ]); + + return; + } + + $user = $this->billable(); + + // They have paid for it here, so make sure their cart cannot charge them for it again + CartItem::query() + ->where('product_id', $product->id) + ->whereHas('cart', fn ($query) => $query->where('user_id', $user->id)->whereNull('completed_at')) + ->delete(); + + // Idempotency: a user can only hold one license per product + if ($product->isOwnedBy($user)) { + Log::info('Product already owned, skipping', [ + 'invoice_id' => $this->invoice->id, + 'product_id' => $product->id, + 'user_id' => $user->id, + ]); + + return; + } + + $this->createProductLicense($user, $product, $this->invoice->total); + + // Thank the buyer for their purchase + $user->notify(new PurchaseReceipt); + + Log::info('Product purchase completed', [ + 'invoice_id' => $this->invoice->id, + 'product_id' => $product->id, + 'user_id' => $user->id, + ]); + } + /** * Resolve which cart items were actually paid for in this invoice. * diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 6a5ff815..90d85d79 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -12,9 +12,11 @@ use App\Models\PluginPrice; use App\Models\User; use App\Support\StripeConnectCountries; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Stripe\Account; +use Stripe\LineItem; use Stripe\Refund; use Stripe\TransferReversal; @@ -278,13 +280,47 @@ protected function determineStatus(Account $account): StripeConnectStatus return StripeConnectStatus::Pending; } - public function refundPaymentIntent(string $paymentIntentId): Refund + /** + * Refund what the buyer paid for one line of a checkout, after coupons and tax. + * + * The line is the idempotency key, so a double submit or a quick retry gets the same + * refund back from Stripe instead of refunding the line a second time. + */ + public function refundCheckoutLineItem(string $paymentIntentId, LineItem $lineItem): Refund { return Cashier::stripe()->refunds->create([ 'payment_intent' => $paymentIntentId, + 'amount' => $lineItem->amount_total, + ], [ + 'idempotency_key' => 'refund-'.$lineItem->id, ]); } + /** + * The line items of the Checkout session a payment intent was paid through, each with + * its product expanded so the product's metadata can be read. + * + * @return Collection + */ + public function checkoutLineItems(string $paymentIntentId): Collection + { + $session = Cashier::stripe()->checkout->sessions->all([ + 'payment_intent' => $paymentIntentId, + 'limit' => 1, + ])->first(); + + if (! $session) { + return collect(); + } + + $lineItems = Cashier::stripe()->checkout->sessions->allLineItems($session->id, [ + 'limit' => 100, + 'expand' => ['data.price.product'], + ]); + + return collect($lineItems->data); + } + public function reverseTransfer(string $stripeTransferId): TransferReversal { return Cashier::stripe()->transfers->retrieve($stripeTransferId) diff --git a/routes/web.php b/routes/web.php index b0e23fb1..3bb8d9cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,7 +46,6 @@ use App\Livewire\PluginDirectory; use App\Models\Course; use App\Models\Product; -use App\Services\CartService; use App\Services\DocsVersionService; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\UrlGenerationException; @@ -158,7 +157,7 @@ ->with('message', 'Please log in or create an account to complete your purchase.'); } - $product = Product::where('slug', 'nativephp-masterclass')->firstOrFail(); + $product = Product::where('slug', 'nativephp-masterclass')->active()->firstOrFail(); if ($product->isOwnedBy($user)) { return to_route('course')->with('error', 'You already own this course.'); @@ -180,11 +179,9 @@ $user->createOrGetStripeCustomer(); - $cartService = resolve(CartService::class); - $cart = $cartService->getCart($user); - $cartService->addProduct($cart, $product); - - $metadata = ['cart_id' => (string) $cart->id]; + // The course is bought on its own, never through the cart, so the webhook + // licenses it from this product ID and leaves the buyer's cart alone. + $metadata = ['product_id' => (string) $product->id]; $sessionOptions = [ 'success_url' => route('cart.success').'?session_id={CHECKOUT_SESSION_ID}', diff --git a/tests/Feature/CheckoutCouponTest.php b/tests/Feature/CheckoutCouponTest.php index a55e2502..005d4d8d 100644 --- a/tests/Feature/CheckoutCouponTest.php +++ b/tests/Feature/CheckoutCouponTest.php @@ -2,6 +2,10 @@ namespace Tests\Feature; +use App\Models\BundlePrice; +use App\Models\Plugin; +use App\Models\PluginBundle; +use App\Models\PluginPrice; use App\Models\Product; use App\Models\ProductPrice; use App\Models\User; @@ -340,4 +344,35 @@ public function cart_checkout_uses_stripe_price_line_item_when_price_is_backed_b $this->assertArrayNotHasKey('price_data', $captured->params['line_items'][0]); $this->assertSame([['coupon' => 'coupon_test123']], $captured->params['discounts']); } + + #[Test] + public function cart_checkout_tags_plugin_and_bundle_lines_with_their_ids_so_a_refund_can_find_them(): void + { + $captured = $this->captureStripeCheckoutParams(); + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $plugin = Plugin::factory()->approved()->paid()->create(['is_active' => true]); + PluginPrice::factory()->regular()->amount(2900)->create(['plugin_id' => $plugin->id]); + + $bundle = PluginBundle::factory()->active()->create(); + $bundle->plugins()->attach(Plugin::factory()->approved()->paid()->create(['is_active' => true])); + BundlePrice::factory()->regular()->amount(9900)->create(['plugin_bundle_id' => $bundle->id]); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($user); + $cartService->addPlugin($cart, $plugin); + $cartService->addBundle($cart, $bundle); + + $this->actingAs($user) + ->post(route('cart.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + + $products = collect($captured->params['line_items'])->pluck('price_data.product_data')->keyBy('name'); + + $this->assertCount(2, $products); + $this->assertSame(['plugin_id' => $plugin->id], $products[$plugin->name]['metadata']); + $this->assertSame(['plugin_bundle_id' => $bundle->id], $products[$bundle->name.' (Bundle)']['metadata']); + } } diff --git a/tests/Feature/CoursePageTest.php b/tests/Feature/CoursePageTest.php index 5aab3453..39b52117 100644 --- a/tests/Feature/CoursePageTest.php +++ b/tests/Feature/CoursePageTest.php @@ -2,16 +2,24 @@ namespace Tests\Feature; +use App\Jobs\HandleInvoicePaidJob; +use App\Models\DeveloperAccount; +use App\Models\Plugin; +use App\Models\PluginPrice; use App\Models\Product; use App\Models\ProductPrice; use App\Models\User; +use App\Notifications\PurchaseReceipt; +use App\Services\CartService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Notification; use Laravel\Cashier\Subscription; use PHPUnit\Framework\Attributes\Test; use Stripe\Checkout\Session; use Stripe\Coupon; use Stripe\Customer; +use Stripe\Invoice; use Stripe\StripeClient; use Tests\TestCase; @@ -43,6 +51,54 @@ public function retrieve(): Coupon $this->app->bind(StripeClient::class, fn () => $mockStripeClient); } + /** + * Bind a mocked StripeClient and return a holder object whose ->params + * property captures the checkout session params sent to Stripe. + */ + private function captureStripeCheckoutParams(): \stdClass + { + $captured = new \stdClass; + $captured->params = null; + + $mockCheckoutSessions = new class($captured) + { + public function __construct(private \stdClass $captured) {} + + public function create(array $params): Session + { + $this->captured->params = $params; + + return Session::constructFrom([ + 'id' => 'cs_test123', + 'url' => 'https://checkout.stripe.com/test-session', + ]); + } + }; + + $mockCheckout = new \stdClass; + $mockCheckout->sessions = $mockCheckoutSessions; + + $mockCustomers = new class + { + public function retrieve(): Customer + { + return Customer::constructFrom([ + 'id' => 'cus_test123', + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->checkout = $mockCheckout; + $mockStripeClient->customers = $mockCustomers; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + return $captured; + } + #[Test] public function course_page_loads_successfully(): void { @@ -211,6 +267,95 @@ public function retrieve(): Customer Carbon::setTestNow(); } + #[Test] + public function course_checkout_does_not_use_the_cart(): void + { + $captured = $this->captureStripeCheckoutParams(); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->firstOrFail(); + $masterclass->prices()->update(['stripe_price_id' => 'price_test123']); + + $this + ->actingAs(User::factory()->create(['stripe_id' => 'cus_test123'])) + ->post(route('course.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertDatabaseCount('carts', 0); + + $invoiceMetadata = $captured->params['invoice_creation']['invoice_data']['metadata']; + + $this->assertSame((string) $masterclass->id, $invoiceMetadata['product_id']); + $this->assertArrayNotHasKey('cart_id', $invoiceMetadata); + } + + #[Test] + public function course_checkout_is_not_found_when_the_masterclass_is_inactive(): void + { + Product::where('slug', 'nativephp-masterclass')->update(['is_active' => false]); + + $this + ->actingAs(User::factory()->create()) + ->post(route('course.checkout')) + ->assertNotFound(); + } + + #[Test] + public function buying_the_masterclass_does_not_license_other_items_in_the_cart(): void + { + Notification::fake(); + + $captured = $this->captureStripeCheckoutParams(); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->firstOrFail(); + $masterclass->prices()->update(['stripe_price_id' => 'price_test123']); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test123']); + + $developerAccount = DeveloperAccount::factory()->create(); + $plugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => false, + 'user_id' => $developerAccount->user_id, + 'developer_account_id' => $developerAccount->id, + ]); + PluginPrice::factory()->regular()->amount(4900)->create(['plugin_id' => $plugin->id]); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($buyer); + $pluginItem = $cartService->addPlugin($cart, $plugin); + + $this + ->actingAs($buyer) + ->post(route('course.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertSame([['price' => 'price_test123', 'quantity' => 1]], $captured->params['line_items']); + + // Stripe copies the session's invoice metadata onto the invoice it reports as paid. + (new HandleInvoicePaidJob(Invoice::constructFrom([ + 'id' => 'in_test_'.uniqid(), + 'billing_reason' => Invoice::BILLING_REASON_MANUAL, + 'customer' => $buyer->stripe_id, + 'payment_intent' => 'pi_test_'.uniqid(), + 'currency' => 'usd', + 'total' => 29900, + 'metadata' => $captured->params['invoice_creation']['invoice_data']['metadata'], + 'lines' => [], + ])))->handle(); + + $this->assertTrue($masterclass->isOwnedBy($buyer)); + Notification::assertSentTo($buyer, PurchaseReceipt::class); + + $this->assertDatabaseCount('plugin_payouts', 0); + Notification::assertNothingSentTo($developerAccount->user); + $this->assertDatabaseCount('plugin_licenses', 0); + $this->assertFalse($buyer->hasPluginAccess($plugin)); + + // The buyer's cart is exactly as they left it. + $this->assertNull($cart->fresh()->completed_at); + $this->assertSame([$pluginItem->id], $cart->items()->pluck('id')->all()); + } + #[Test] public function course_checkout_returns_error_when_price_id_not_configured(): void { diff --git a/tests/Feature/Filament/ThirdPartySaleResourceTest.php b/tests/Feature/Filament/ThirdPartySaleResourceTest.php index f1516999..9d934847 100644 --- a/tests/Feature/Filament/ThirdPartySaleResourceTest.php +++ b/tests/Feature/Filament/ThirdPartySaleResourceTest.php @@ -9,8 +9,14 @@ use App\Models\PluginLicense; use App\Models\PluginPayout; use App\Models\User; +use App\Services\StripeConnectService; +use Filament\Notifications\Notification; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; +use Mockery; +use Mockery\MockInterface; +use Stripe\LineItem; +use Stripe\Refund; use Tests\TestCase; class ThirdPartySaleResourceTest extends TestCase @@ -236,4 +242,63 @@ public function test_refund_action_is_only_visible_for_refundable_sales(): void ->assertTableActionHidden('refund', $tooOld) ->assertTableActionHidden('refund', $alreadyRefunded); } + + public function test_refund_confirmation_explains_what_will_be_refunded(): void + { + $plugin = $this->createThirdPartyPlugin(); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'purchased_at' => now()->subDays(3), + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->mountTableAction('refund', $license) + ->assertMountedActionModalSee("This will refund {$license->user->email} what they paid for {$plugin->name}, after any coupon or tax, and revoke their license."); + } + + public function test_refund_action_reports_the_amount_refunded(): void + { + $plugin = $this->createThirdPartyPlugin(); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 2900, + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license, $plugin) { + $mock->shouldReceive('checkoutLineItems') + ->with($license->stripe_payment_intent_id) + ->andReturn(collect([ + LineItem::constructFrom([ + 'amount_subtotal' => 2900, + 'amount_discount' => 580, + 'amount_tax' => 464, + 'amount_total' => 2784, + 'description' => $plugin->name, + 'price' => ['product' => ['id' => 'prod_test_123', 'metadata' => ['plugin_id' => (string) $plugin->id]]], + ]), + ])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($license->stripe_payment_intent_id, Mockery::on(fn (LineItem $lineItem): bool => $lineItem->amount_total === 2784)) + ->andReturn(Refund::constructFrom(['id' => 're_test_refund_123'])); + }); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->callTableAction('refund', $license) + ->assertNotified( + Notification::make() + ->title('Purchase refunded successfully') + ->body('Refunded $27.84.') + ->success(), + ); + + $license->refresh(); + + $this->assertTrue($license->isRefunded()); + $this->assertSame('re_test_refund_123', $license->stripe_refund_id); + $this->assertSame($this->admin->id, $license->refunded_by); + } } diff --git a/tests/Feature/Jobs/HandleInvoicePaidJobTest.php b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php index 74c8f6a3..40a1823e 100644 --- a/tests/Feature/Jobs/HandleInvoicePaidJobTest.php +++ b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php @@ -8,6 +8,8 @@ use App\Models\CartItem; use App\Models\Plugin; use App\Models\PluginBundle; +use App\Models\Product; +use App\Models\ProductLicense; use App\Models\User; use App\Notifications\PurchaseReceipt; use App\Notifications\UltraSubscriptionStarted; @@ -266,6 +268,140 @@ public function it_only_licenses_cart_items_recorded_in_the_invoice_snapshot(): $this->assertNotNull($cart->fresh()->completed_at); } + #[Test] + public function it_licenses_a_product_bought_outside_the_cart(): void + { + Notification::fake(); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']); + $product = Product::factory()->active()->create(); + + $invoice = $this->createProductInvoice($buyer, $product->id, total: 19900); + + (new HandleInvoicePaidJob($invoice))->handle(); + + $this->assertDatabaseHas('product_licenses', [ + 'user_id' => $buyer->id, + 'product_id' => $product->id, + 'stripe_invoice_id' => $invoice->id, + 'stripe_payment_intent_id' => $invoice->payment_intent, + 'price_paid' => 19900, + 'currency' => 'USD', + ]); + + Notification::assertSentToTimes($buyer, PurchaseReceipt::class, 1); + } + + #[Test] + public function it_does_not_license_a_directly_bought_product_twice(): void + { + Notification::fake(); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']); + $product = Product::factory()->active()->create(); + + $invoice = $this->createProductInvoice($buyer, $product->id); + + (new HandleInvoicePaidJob($invoice))->handle(); + (new HandleInvoicePaidJob($invoice))->handle(); + + // A second payment for a product the buyer already owns has nothing left to grant. + (new HandleInvoicePaidJob($this->createProductInvoice($buyer, $product->id)))->handle(); + + $this->assertSame($invoice->id, $buyer->productLicenses()->sole()->stripe_invoice_id); + Notification::assertSentToTimes($buyer, PurchaseReceipt::class, 1); + } + + #[Test] + public function it_takes_a_directly_bought_product_out_of_the_buyers_cart(): void + { + Notification::fake(); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']); + $product = Product::factory()->active()->create(); + $plugin = Plugin::factory()->approved()->create(['is_active' => true]); + + $cart = Cart::factory()->for($buyer)->create(); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'product_price_at_addition' => 29900, + ]); + + $pluginItem = CartItem::create([ + 'cart_id' => $cart->id, + 'plugin_id' => $plugin->id, + 'price_at_addition' => 4900, + ]); + + // Someone else has the same product waiting in their own cart. + $otherBuyersItem = CartItem::create([ + 'cart_id' => Cart::factory()->create()->id, + 'product_id' => $product->id, + 'product_price_at_addition' => 29900, + ]); + + // A cart the buyer completed in the past keeps its record of the product. + $completedCartItem = CartItem::create([ + 'cart_id' => Cart::factory()->for($buyer)->create(['completed_at' => now()->subMonth()])->id, + 'product_id' => $product->id, + 'product_price_at_addition' => 29900, + ]); + + (new HandleInvoicePaidJob($this->createProductInvoice($buyer, $product->id)))->handle(); + + $this->assertTrue($product->isOwnedBy($buyer)); + + $this->assertNull($cart->fresh()->completed_at); + $this->assertSame([$pluginItem->id], $cart->items()->pluck('id')->all()); + $this->assertEquals(0, $buyer->pluginLicenses()->count()); + + $this->assertModelExists($otherBuyersItem); + $this->assertModelExists($completedCartItem); + } + + #[Test] + public function it_takes_a_product_the_buyer_already_owns_out_of_their_cart(): void + { + Notification::fake(); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']); + $product = Product::factory()->active()->create(); + + ProductLicense::factory()->create([ + 'user_id' => $buyer->id, + 'product_id' => $product->id, + ]); + + $cart = Cart::factory()->for($buyer)->create(); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'product_price_at_addition' => 29900, + ]); + + (new HandleInvoicePaidJob($this->createProductInvoice($buyer, $product->id)))->handle(); + + $this->assertEquals(0, $cart->items()->count()); + $this->assertEquals(1, $buyer->productLicenses()->count()); + Notification::assertNothingSentTo($buyer); + } + + #[Test] + public function it_licenses_nothing_for_a_direct_purchase_of_an_unknown_product(): void + { + Notification::fake(); + + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']); + + (new HandleInvoicePaidJob($this->createProductInvoice($buyer, 999999)))->handle(); + + $this->assertDatabaseCount('product_licenses', 0); + Notification::assertNothingSentTo($buyer); + } + public static function subscriptionPlanProvider(): array { return [ @@ -315,6 +451,20 @@ private function createStripeInvoice( ]); } + private function createProductInvoice(User $buyer, int $productId, int $total = 29900): Invoice + { + return Invoice::constructFrom([ + 'id' => 'in_test_'.uniqid(), + 'billing_reason' => Invoice::BILLING_REASON_MANUAL, + 'customer' => $buyer->stripe_id, + 'payment_intent' => 'pi_test_'.uniqid(), + 'currency' => 'usd', + 'total' => $total, + 'metadata' => ['product_id' => (string) $productId], + 'lines' => [], + ]); + } + private function mockStripeSubscriptionRetrieve(string $subscriptionId): void { $mockSubscription = Subscription::constructFrom([ diff --git a/tests/Feature/PluginPurchaseRefundTest.php b/tests/Feature/PluginPurchaseRefundTest.php index ad9d137c..347d7b4e 100644 --- a/tests/Feature/PluginPurchaseRefundTest.php +++ b/tests/Feature/PluginPurchaseRefundTest.php @@ -17,9 +17,12 @@ use App\Services\CartService; use App\Services\StripeConnectService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Mockery; +use Mockery\Matcher\Closure as MockeryClosure; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; use Stripe\Invoice; +use Stripe\LineItem; use Stripe\Refund; use Stripe\TransferReversal; use Tests\TestCase; @@ -67,10 +70,58 @@ private function makeStripeTransferReversal(string $id = 'trr_test_reversal_123' return TransferReversal::constructFrom(['id' => $id]); } - private function mockStripeConnectService(?MockInterface &$mock = null): void + /** + * A line of a Stripe checkout, the way Stripe returns it with the product expanded. + * It is charged at $29.00 with no discount or tax unless $amounts says otherwise. + * + * @param array $metadata The product's metadata. Checkouts from before lines were tagged have none. + * @param array $amounts + */ + private function makeStripeLineItem(string $description, array $metadata = [], array $amounts = []): LineItem { - $this->mock(StripeConnectService::class, function (MockInterface $m) use (&$mock) { - $m->shouldReceive('refundPaymentIntent') + return LineItem::constructFrom(array_merge([ + 'amount_subtotal' => 2900, + 'amount_discount' => 0, + 'amount_tax' => 0, + 'amount_total' => 2900, + 'description' => $description, + 'price' => ['product' => ['id' => 'prod_test_123', 'metadata' => $metadata]], + ], $amounts)); + } + + /** + * @param array $amounts + */ + private function makePluginLineItem(Plugin $plugin, array $amounts = []): LineItem + { + return $this->makeStripeLineItem($plugin->name, ['plugin_id' => (string) $plugin->id], $amounts); + } + + /** + * @param array $amounts + */ + private function makeBundleLineItem(PluginBundle $bundle, array $amounts = []): LineItem + { + return $this->makeStripeLineItem($bundle->name.' (Bundle)', ['plugin_bundle_id' => (string) $bundle->id], $amounts); + } + + /** + * Matches the checkout line handed to Stripe for refunding, by what was charged for it. + */ + private function lineItemCharged(int $amount): MockeryClosure + { + return Mockery::on(fn (LineItem $lineItem): bool => $lineItem->amount_total === $amount); + } + + /** + * @param array $lineItems The lines of the Stripe checkout the license was bought in. + */ + private function mockStripeConnectService(array $lineItems = [], ?MockInterface &$mock = null): void + { + $this->mock(StripeConnectService::class, function (MockInterface $m) use ($lineItems, &$mock) { + $m->shouldReceive('checkoutLineItems') + ->andReturn(collect($lineItems)); + $m->shouldReceive('refundCheckoutLineItem') ->andReturn($this->makeStripeRefund()); $m->shouldReceive('reverseTransfer') ->andReturn($this->makeStripeTransferReversal()); @@ -85,7 +136,7 @@ public function refund_within_14_days_succeeds(): void 'purchased_at' => now()->subDays(5), ]); - $this->mockStripeConnectService(); + $this->mockStripeConnectService([$this->makePluginLineItem($license->plugin)]); $admin = User::factory()->create(); app(RefundPluginPurchase::class)->handle($license, $admin); @@ -181,7 +232,7 @@ public function pending_payout_gets_cancelled_on_refund(): void ); $mock = null; - $this->mockStripeConnectService($mock); + $this->mockStripeConnectService([$this->makePluginLineItem($license->plugin)], $mock); $mock->shouldNotHaveReceived('reverseTransfer'); app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); @@ -198,7 +249,7 @@ public function held_payout_gets_cancelled_on_refund(): void 'held', ); - $this->mockStripeConnectService(); + $this->mockStripeConnectService([$this->makePluginLineItem($license->plugin)]); app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); @@ -213,7 +264,7 @@ public function failed_payout_gets_cancelled_on_refund_so_it_cannot_be_retried() 'failed', ); - $this->mockStripeConnectService(); + $this->mockStripeConnectService([$this->makePluginLineItem($license->plugin)]); app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); @@ -230,8 +281,10 @@ public function transferred_payout_gets_reversed_and_cancelled_on_refund(): void 'transferred', ); - $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($payout) { - $mock->shouldReceive('refundPaymentIntent') + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license, $payout) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([$this->makePluginLineItem($license->plugin)])); + $mock->shouldReceive('refundCheckoutLineItem') ->once() ->andReturn($this->makeStripeRefund()); $mock->shouldReceive('reverseTransfer') @@ -246,6 +299,65 @@ public function transferred_payout_gets_reversed_and_cancelled_on_refund(): void $this->assertEquals(PayoutStatus::Cancelled, $payout->status); } + #[Test] + public function refunding_one_plugin_from_a_multi_item_checkout_only_refunds_that_line(): void + { + $buyer = User::factory()->create(); + $paymentIntentId = 'pi_multi_item_test_123'; + + [$license, $payout] = $this->createLicenseWithPayout([ + 'user_id' => $buyer->id, + 'stripe_payment_intent_id' => $paymentIntentId, + 'purchased_at' => now()->subDays(3), + 'price_paid' => 2900, + ]); + [$otherLicense, $otherPayout] = $this->createLicenseWithPayout([ + 'user_id' => $buyer->id, + 'stripe_payment_intent_id' => $paymentIntentId, + 'purchased_at' => now()->subDays(3), + 'price_paid' => 4900, + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license, $otherLicense, $paymentIntentId) { + $mock->shouldReceive('checkoutLineItems') + ->once() + ->with($paymentIntentId) + ->andReturn(collect([ + $this->makePluginLineItem($otherLicense->plugin, [ + 'amount_subtotal' => 4900, + 'amount_discount' => 980, + 'amount_tax' => 784, + 'amount_total' => 4704, + ]), + $this->makePluginLineItem($license->plugin, [ + 'amount_subtotal' => 2900, + 'amount_discount' => 580, + 'amount_tax' => 464, + 'amount_total' => 2784, + ]), + ])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($paymentIntentId, $this->lineItemCharged(2784)) + ->andReturn($this->makeStripeRefund()); + $mock->shouldReceive('reverseTransfer')->never(); + }); + + $amount = app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $this->assertSame(2784, $amount); + + $license->refresh(); + $this->assertNotNull($license->refunded_at); + $this->assertEquals('re_test_refund_123', $license->stripe_refund_id); + $this->assertEquals(PayoutStatus::Cancelled, $payout->refresh()->status); + + $otherLicense->refresh(); + $this->assertNull($otherLicense->refunded_at); + $this->assertNull($otherLicense->stripe_refund_id); + $this->assertEquals(PayoutStatus::Pending, $otherPayout->refresh()->status); + } + #[Test] public function bundle_refund_processes_all_sibling_licenses(): void { @@ -272,14 +384,31 @@ public function bundle_refund_processes_all_sibling_licenses(): void $licenses->push($license); } - $this->mock(StripeConnectService::class, function (MockInterface $mock) { - $mock->shouldReceive('refundPaymentIntent') + $boughtAlongside = Plugin::factory()->paid()->create(); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($bundle, $boughtAlongside, $paymentIntentId) { + $mock->shouldReceive('checkoutLineItems') ->once() + ->with($paymentIntentId) + ->andReturn(collect([ + $this->makePluginLineItem($boughtAlongside), + $this->makeBundleLineItem($bundle, [ + 'amount_subtotal' => 9000, + 'amount_discount' => 1800, + 'amount_tax' => 1440, + 'amount_total' => 8640, + ]), + ])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($paymentIntentId, $this->lineItemCharged(8640)) ->andReturn($this->makeStripeRefund('re_bundle_refund')); $mock->shouldReceive('reverseTransfer')->never(); }); - app(RefundPluginPurchase::class)->handle($licenses->first(), User::factory()->create()); + $amount = app(RefundPluginPurchase::class)->handle($licenses->first(), User::factory()->create()); + + $this->assertSame(8640, $amount); foreach ($licenses as $license) { $license->refresh(); @@ -291,14 +420,214 @@ public function bundle_refund_processes_all_sibling_licenses(): void } #[Test] - public function stripe_failure_leaves_everything_unchanged(): void + public function plugin_line_is_matched_by_id_even_after_the_plugin_is_renamed(): void + { + [$license] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(3), + ]); + + $lineItem = $this->makePluginLineItem($license->plugin); + $license->plugin->update(['name' => 'acme/renamed-since-purchase']); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license, $lineItem) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([$lineItem])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($license->stripe_payment_intent_id, $this->lineItemCharged(2900)) + ->andReturn($this->makeStripeRefund()); + }); + + $amount = app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $this->assertSame(2900, $amount); + $this->assertTrue($license->fresh()->isRefunded()); + } + + #[Test] + public function bundle_line_is_matched_by_id_even_after_the_bundle_is_renamed(): void + { + $bundle = PluginBundle::factory()->create(); + [$license] = $this->createLicenseWithPayout([ + 'plugin_bundle_id' => $bundle->id, + 'purchased_at' => now()->subDays(3), + ]); + + $lineItem = $this->makeBundleLineItem($bundle); + $bundle->update(['name' => 'Renamed Since Purchase']); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license, $lineItem) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([$lineItem])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($license->stripe_payment_intent_id, $this->lineItemCharged(2900)) + ->andReturn($this->makeStripeRefund()); + }); + + $amount = app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $this->assertSame(2900, $amount); + $this->assertTrue($license->fresh()->isRefunded()); + } + + #[Test] + public function plugin_line_without_metadata_is_matched_by_name(): void + { + [$license] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([ + $this->makeStripeLineItem('The NativePHP Masterclass', amounts: [ + 'amount_subtotal' => 29900, + 'amount_total' => 29900, + ]), + $this->makeStripeLineItem($license->plugin->name), + ])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($license->stripe_payment_intent_id, $this->lineItemCharged(2900)) + ->andReturn($this->makeStripeRefund()); + }); + + $amount = app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $this->assertSame(2900, $amount); + $this->assertTrue($license->fresh()->isRefunded()); + } + + #[Test] + public function bundle_line_without_metadata_is_matched_by_name_with_the_bundle_suffix(): void + { + $bundle = PluginBundle::factory()->create(); + [$license] = $this->createLicenseWithPayout([ + 'plugin_bundle_id' => $bundle->id, + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($bundle, $license) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([ + $this->makeStripeLineItem($license->plugin->name), + $this->makeStripeLineItem($bundle->name.' (Bundle)', amounts: [ + 'amount_subtotal' => 9900, + 'amount_total' => 9900, + ]), + ])); + $mock->shouldReceive('refundCheckoutLineItem') + ->once() + ->with($license->stripe_payment_intent_id, $this->lineItemCharged(9900)) + ->andReturn($this->makeStripeRefund()); + }); + + $amount = app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $this->assertSame(9900, $amount); + $this->assertTrue($license->fresh()->isRefunded()); + } + + #[Test] + public function refund_is_blocked_when_the_checkout_only_has_a_line_for_something_else(): void { [$license, $payout] = $this->createLicenseWithPayout([ 'purchased_at' => now()->subDays(3), ]); $this->mock(StripeConnectService::class, function (MockInterface $mock) { - $mock->shouldReceive('refundPaymentIntent') + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([ + $this->makeStripeLineItem('The NativePHP Masterclass', amounts: [ + 'amount_subtotal' => 29900, + 'amount_total' => 29900, + ]), + ])); + $mock->shouldReceive('refundCheckoutLineItem')->never(); + $mock->shouldReceive('reverseTransfer')->never(); + }); + + $this->assertThrows( + fn () => app(RefundPluginPurchase::class)->handle($license, User::factory()->create()), + \RuntimeException::class, + 'Could not find this license on its Stripe checkout, so there is no amount to refund.', + ); + + $license->refresh(); + $this->assertNull($license->refunded_at); + $this->assertNull($license->stripe_refund_id); + $this->assertEquals(PayoutStatus::Pending, $payout->refresh()->status); + } + + #[Test] + public function refund_is_blocked_when_nothing_was_charged_for_the_line(): void + { + [$license, $payout] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([ + $this->makePluginLineItem($license->plugin, [ + 'amount_discount' => 2900, + 'amount_total' => 0, + ]), + ])); + $mock->shouldReceive('refundCheckoutLineItem')->never(); + $mock->shouldReceive('reverseTransfer')->never(); + }); + + $this->assertThrows( + fn () => app(RefundPluginPurchase::class)->handle($license, User::factory()->create()), + \RuntimeException::class, + 'Nothing was charged for this license, so there is nothing to refund.', + ); + + $license->refresh(); + $this->assertNull($license->refunded_at); + $this->assertNull($license->stripe_refund_id); + $this->assertEquals(PayoutStatus::Pending, $payout->refresh()->status); + } + + #[Test] + public function refund_is_blocked_when_the_checkout_has_no_line_items(): void + { + [$license, $payout] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect()); + $mock->shouldReceive('refundCheckoutLineItem')->never(); + $mock->shouldReceive('reverseTransfer')->never(); + }); + + $this->assertThrows( + fn () => app(RefundPluginPurchase::class)->handle($license, User::factory()->create()), + \RuntimeException::class, + 'Could not find this license on its Stripe checkout, so there is no amount to refund.', + ); + + $license->refresh(); + $this->assertNull($license->refunded_at); + $this->assertNull($license->stripe_refund_id); + $this->assertEquals(PayoutStatus::Pending, $payout->refresh()->status); + } + + #[Test] + public function stripe_failure_leaves_everything_unchanged(): void + { + [$license, $payout] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($license) { + $mock->shouldReceive('checkoutLineItems') + ->andReturn(collect([$this->makePluginLineItem($license->plugin)])); + $mock->shouldReceive('refundCheckoutLineItem') ->once() ->andThrow(new \Exception('Stripe API error')); }); @@ -339,7 +668,7 @@ public function buyer_loses_access_when_refunded_and_regains_it_by_buying_again( ]); $this->assertTrue($buyer->hasPluginAccess($plugin)); - $this->mockStripeConnectService(); + $this->mockStripeConnectService([$this->makePluginLineItem($plugin)]); app(RefundPluginPurchase::class)->handle($firstPurchase, User::factory()->create()); $this->assertFalse($buyer->hasPluginAccess($plugin)); diff --git a/tests/Feature/Services/StripeConnectServiceTest.php b/tests/Feature/Services/StripeConnectServiceTest.php index f69aed4d..032ea9a3 100644 --- a/tests/Feature/Services/StripeConnectServiceTest.php +++ b/tests/Feature/Services/StripeConnectServiceTest.php @@ -14,7 +14,10 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Stripe\Account; +use Stripe\Collection as StripeCollection; +use Stripe\LineItem; use Stripe\PaymentIntent; +use Stripe\Refund; use Stripe\StripeClient; use Stripe\Transfer; use Tests\TestCase; @@ -306,6 +309,163 @@ public static function disabledAccounts(): array ]; } + #[Test] + public function refund_checkout_line_item_refunds_only_what_was_charged_for_that_line(): void + { + $refunds = new class + { + public ?array $createdWith = null; + + public ?array $createdWithOptions = null; + + public function create(array $params, array $options): Refund + { + $this->createdWith = $params; + $this->createdWithOptions = $options; + + return Refund::constructFrom(['id' => 're_test_123']); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->refunds = $refunds; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + $lineItem = LineItem::constructFrom([ + 'id' => 'li_test_plugin', + 'amount_subtotal' => 2900, + 'amount_discount' => 580, + 'amount_tax' => 464, + 'amount_total' => 2784, + ]); + + $refund = app(StripeConnectService::class)->refundCheckoutLineItem('pi_test_123', $lineItem); + + $this->assertSame('re_test_123', $refund->id); + $this->assertSame([ + 'payment_intent' => 'pi_test_123', + 'amount' => 2784, + ], $refunds->createdWith); + + // Asking Stripe again for the same line must not create a second refund. + $this->assertSame(['idempotency_key' => 'refund-li_test_plugin'], $refunds->createdWithOptions); + } + + #[Test] + public function checkout_line_items_returns_the_lines_of_the_checkout_session_for_a_payment_intent(): void + { + $sessions = $this->fakeStripeCheckoutSessions( + sessions: [['id' => 'cs_test_123', 'object' => 'checkout.session']], + lineItems: [ + [ + 'id' => 'li_test_plugin', + 'object' => 'item', + 'amount_subtotal' => 2900, + 'amount_discount' => 580, + 'amount_tax' => 464, + 'amount_total' => 2784, + 'description' => 'acme/camera', + 'price' => [ + 'id' => 'price_test_plugin', + 'object' => 'price', + 'product' => [ + 'id' => 'prod_test_plugin', + 'object' => 'product', + 'metadata' => ['plugin_id' => '7'], + ], + ], + ], + [ + 'id' => 'li_test_bundle', + 'object' => 'item', + 'amount_subtotal' => 9900, + 'amount_discount' => 0, + 'amount_tax' => 0, + 'amount_total' => 9900, + 'description' => 'Starter (Bundle)', + 'price' => [ + 'id' => 'price_test_bundle', + 'object' => 'price', + 'product' => [ + 'id' => 'prod_test_bundle', + 'object' => 'product', + 'metadata' => ['plugin_bundle_id' => '3'], + ], + ], + ], + ], + ); + + $lineItems = app(StripeConnectService::class)->checkoutLineItems('pi_test_123'); + + $this->assertSame(['payment_intent' => 'pi_test_123', 'limit' => 1], $sessions->listedWith); + $this->assertSame('cs_test_123', $sessions->lineItemsListedFor); + $this->assertSame(['limit' => 100, 'expand' => ['data.price.product']], $sessions->lineItemsListedWith); + + $this->assertCount(2, $lineItems); + $this->assertContainsOnlyInstancesOf(LineItem::class, $lineItems); + $this->assertSame(['li_test_plugin', 'li_test_bundle'], $lineItems->pluck('id')->all()); + $this->assertSame(2784, $lineItems[0]->amount_total); + $this->assertSame('7', $lineItems[0]->price->product->metadata['plugin_id']); + $this->assertSame('3', $lineItems[1]->price->product->metadata['plugin_bundle_id']); + } + + #[Test] + public function checkout_line_items_is_empty_when_the_payment_intent_has_no_checkout_session(): void + { + $sessions = $this->fakeStripeCheckoutSessions(sessions: []); + + $lineItems = app(StripeConnectService::class)->checkoutLineItems('pi_test_no_session'); + + $this->assertTrue($lineItems->isEmpty()); + $this->assertSame(['payment_intent' => 'pi_test_no_session', 'limit' => 1], $sessions->listedWith); + $this->assertNull($sessions->lineItemsListedFor); + } + + /** + * @param array> $sessions The Checkout sessions Stripe finds for the payment intent. + * @param array> $lineItems The line items of the session. + */ + private function fakeStripeCheckoutSessions(array $sessions, array $lineItems = []): object + { + $checkoutSessions = new class($sessions, $lineItems) + { + public ?array $listedWith = null; + + public ?string $lineItemsListedFor = null; + + public ?array $lineItemsListedWith = null; + + public function __construct(private array $sessions, private array $lineItems) {} + + public function all(array $params): StripeCollection + { + $this->listedWith = $params; + + return StripeCollection::constructFrom(['object' => 'list', 'data' => $this->sessions]); + } + + public function allLineItems(string $id, array $params): StripeCollection + { + $this->lineItemsListedFor = $id; + $this->lineItemsListedWith = $params; + + return StripeCollection::constructFrom(['object' => 'list', 'data' => $this->lineItems]); + } + }; + + $mockCheckout = new \stdClass; + $mockCheckout->sessions = $checkoutSessions; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->checkout = $mockCheckout; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + return $checkoutSessions; + } + /** * @param array|null $retrieved What Stripe returns when the account is retrieved. */