From 05cf8c6b4012b1371cd98860c69206d822ff8966 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 2 Oct 2026 11:16:31 +0100 Subject: [PATCH 1/2] Sell the Masterclass outside the cart The /course checkout added the Masterclass to the buyer's cart and sent Stripe only the cart ID. When the invoice was paid, the webhook job licensed everything in that cart, so plugins and bundles the buyer had not paid for were licensed and their developers got a payout. The course is now a direct purchase. The checkout sends the product ID and never touches the cart, and the job licenses that one product from the invoice. If an earlier checkout left the Masterclass in the buyer's cart, it is removed once they have paid so the cart cannot charge for it again. Co-Authored-By: Claude Opus 5.5 --- app/Jobs/HandleInvoicePaidJob.php | 51 ++++++ routes/web.php | 11 +- tests/Feature/CoursePageTest.php | 145 +++++++++++++++++ .../Feature/Jobs/HandleInvoicePaidJobTest.php | 150 ++++++++++++++++++ 4 files changed, 350 insertions(+), 7 deletions(-) diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index 896fc2c43..f58edc149 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/routes/web.php b/routes/web.php index b0e23fb19..3bb8d9cb3 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/CoursePageTest.php b/tests/Feature/CoursePageTest.php index 5aab34530..39b52117d 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/Jobs/HandleInvoicePaidJobTest.php b/tests/Feature/Jobs/HandleInvoicePaidJobTest.php index 74c8f6a3b..40a1823e3 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([ From 1f797a3831003e0d5af27b95b5e54c0c6d8be220 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 2 Oct 2026 11:16:31 +0100 Subject: [PATCH 2/2] Refund one product at a time, for what was paid Refunding a plugin license refunded the whole payment, so every other item bought in the same checkout was refunded too while only the one license was revoked. The refund now covers the license's own line of the Stripe checkout, at Stripe's total for that line after coupons and tax. The cart checkout tags each line with its plugin or bundle ID so the line can be found. Checkouts made before that are matched by name. The line item is the idempotency key, so a repeated request gets the same refund back. Co-Authored-By: Claude Opus 5.5 --- app/Actions/RefundPluginPurchase.php | 56 ++- .../Actions/RefundPluginLicenseAction.php | 6 +- app/Http/Controllers/CartController.php | 2 + app/Services/StripeConnectService.php | 38 +- tests/Feature/CheckoutCouponTest.php | 35 ++ .../Filament/ThirdPartySaleResourceTest.php | 65 ++++ tests/Feature/PluginPurchaseRefundTest.php | 359 +++++++++++++++++- .../Services/StripeConnectServiceTest.php | 160 ++++++++ 8 files changed, 699 insertions(+), 22 deletions(-) diff --git a/app/Actions/RefundPluginPurchase.php b/app/Actions/RefundPluginPurchase.php index 10b9a500e..8b5c7a940 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 a727aab25..acceba7ad 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 06b3b240c..8849af50a 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/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 6a5ff8157..90d85d791 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/tests/Feature/CheckoutCouponTest.php b/tests/Feature/CheckoutCouponTest.php index a55e25027..005d4d8d2 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/Filament/ThirdPartySaleResourceTest.php b/tests/Feature/Filament/ThirdPartySaleResourceTest.php index f15169998..9d9348475 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/PluginPurchaseRefundTest.php b/tests/Feature/PluginPurchaseRefundTest.php index ad9d137c0..347d7b4e8 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 f69aed4dc..032ea9a36 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. */