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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions app/Actions/RefundPluginPurchase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Services\StripeConnectService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Stripe\LineItem;

class RefundPluginPurchase
{
Expand All @@ -15,18 +16,25 @@ 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.');
}

$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) {
Expand All @@ -49,6 +57,8 @@ public function handle(PluginLicense $license, User $refundedBy): void
$payout->markAsCancelled();
}
});

return $lineItem->amount_total;
}

/**
Expand All @@ -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);
}
}
6 changes: 3 additions & 3 deletions app/Filament/Actions/RefundPluginLicenseAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/CartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions app/Jobs/HandleInvoicePaidJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
38 changes: 37 additions & 1 deletion app/Services/StripeConnectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<int, LineItem>
*/
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)
Expand Down
11 changes: 4 additions & 7 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
Expand All @@ -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}',
Expand Down
35 changes: 35 additions & 0 deletions tests/Feature/CheckoutCouponTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}
}
Loading
Loading