Skip to content

Commit 700e2cc

Browse files
simonhampclaude
andauthored
Only license cart items actually paid for at checkout (#406)
Snapshot the purchased cart item IDs into the Stripe checkout session metadata and reconcile against them in the invoice.paid webhook, so leftover/merged cart items are never licensed without payment. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9fef142 commit 700e2cc

3 files changed

Lines changed: 103 additions & 2 deletions

File tree

app/Http/Controllers/CartController.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session
455455
$cart->load('items.plugin', 'items.pluginBundle.plugins', 'items.product');
456456

457457
$lineItems = [];
458+
$purchasedItemIds = [];
458459

459460
Log::info('Creating multi-item checkout session', [
460461
'cart_id' => $cart->id,
@@ -463,6 +464,8 @@ protected function createMultiItemCheckoutSession($cart, $user): Session
463464
]);
464465

465466
foreach ($cart->items as $item) {
467+
$purchasedItemIds[] = $item->id;
468+
466469
if ($item->isBundle()) {
467470
$bundle = $item->pluginBundle;
468471

@@ -516,9 +519,12 @@ protected function createMultiItemCheckoutSession($cart, $user): Session
516519
// Ensure the user has a valid Stripe customer ID
517520
$this->ensureValidStripeCustomer($user);
518521

519-
// Metadata only needs cart_id - we'll look up items from the cart
522+
// Record the exact cart items that were turned into priced line items so the
523+
// webhook only grants licenses for what was actually purchased, not whatever
524+
// happens to be in the cart when the payment is confirmed.
520525
$metadata = [
521526
'cart_id' => (string) $cart->id,
527+
'cart_item_ids' => implode(',', $purchasedItemIds),
522528
];
523529

524530
$session = Cashier::stripe()->checkout->sessions->create([

app/Jobs/HandleInvoicePaidJob.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Illuminate\Foundation\Bus\Dispatchable;
2525
use Illuminate\Queue\InteractsWithQueue;
2626
use Illuminate\Queue\SerializesModels;
27+
use Illuminate\Support\Collection;
2728
use Illuminate\Support\Facades\Date;
2829
use Illuminate\Support\Facades\Log;
2930
use Laravel\Cashier\Cashier;
@@ -259,14 +260,17 @@ private function processCartPurchase(string $cartId): void
259260
return;
260261
}
261262

263+
$purchasedItems = $this->resolvePurchasedItems($cart);
264+
262265
Log::info('Processing cart purchase from invoice', [
263266
'invoice_id' => $this->invoice->id,
264267
'cart_id' => $cartId,
265268
'user_id' => $user->id,
266269
'item_count' => $cart->items->count(),
270+
'purchased_item_count' => $purchasedItems->count(),
267271
]);
268272

269-
foreach ($cart->items as $item) {
273+
foreach ($purchasedItems as $item) {
270274
if ($item->isProduct()) {
271275
$this->processCartProductItem($user, $item);
272276
} elseif ($item->isBundle()) {
@@ -295,6 +299,34 @@ private function processCartPurchase(string $cartId): void
295299
]);
296300
}
297301

302+
/**
303+
* Resolve which cart items were actually paid for in this invoice.
304+
*
305+
* The checkout session snapshots the purchased cart item IDs into the invoice
306+
* metadata. We grant licenses only for those items so that anything else sitting
307+
* in the cart at the time the payment is confirmed (e.g. items left over from an
308+
* earlier browsing session or a merged guest cart) is never licensed for free.
309+
*/
310+
private function resolvePurchasedItems(Cart $cart): Collection
311+
{
312+
$snapshot = (string) ($this->invoice->metadata['cart_item_ids'] ?? '');
313+
314+
if ($snapshot === '') {
315+
// Legacy checkout sessions created before purchased items were snapshotted
316+
// do not carry this metadata; fall back to the full cart for those.
317+
Log::warning('Invoice has no cart_item_ids snapshot, processing entire cart', [
318+
'invoice_id' => $this->invoice->id,
319+
'cart_id' => $cart->id,
320+
]);
321+
322+
return $cart->items;
323+
}
324+
325+
$purchasedItemIds = array_filter(array_map('intval', explode(',', $snapshot)));
326+
327+
return $cart->items->whereIn('id', $purchasedItemIds)->values();
328+
}
329+
298330
private function processCartPluginItem(User $user, CartItem $item): void
299331
{
300332
$plugin = $item->plugin;

tests/Feature/Jobs/HandleInvoicePaidJobTest.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Models\Cart;
88
use App\Models\CartItem;
99
use App\Models\Plugin;
10+
use App\Models\PluginBundle;
1011
use App\Models\User;
1112
use App\Notifications\PurchaseReceipt;
1213
use App\Notifications\UltraSubscriptionStarted;
@@ -203,6 +204,68 @@ public function it_sends_a_purchase_receipt_to_the_buyer_after_a_cart_purchase()
203204
Notification::assertSentTo($buyer, PurchaseReceipt::class);
204205
}
205206

207+
#[Test]
208+
public function it_only_licenses_cart_items_recorded_in_the_invoice_snapshot(): void
209+
{
210+
Notification::fake();
211+
212+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer']);
213+
214+
$purchasedPlugin = Plugin::factory()->approved()->create(['is_active' => true]);
215+
216+
$leftoverPlugin = Plugin::factory()->approved()->create(['is_active' => true]);
217+
$leftoverBundle = PluginBundle::factory()->create();
218+
$leftoverBundle->plugins()->attach($leftoverPlugin->id, ['sort_order' => 1]);
219+
220+
$cart = Cart::factory()->for($buyer)->create();
221+
222+
$purchasedItem = CartItem::create([
223+
'cart_id' => $cart->id,
224+
'plugin_id' => $purchasedPlugin->id,
225+
'price_at_addition' => 2500,
226+
]);
227+
228+
// This bundle is still sitting in the cart but was never part of the checkout.
229+
CartItem::create([
230+
'cart_id' => $cart->id,
231+
'plugin_bundle_id' => $leftoverBundle->id,
232+
'bundle_price_at_addition' => 9999,
233+
]);
234+
235+
$invoice = Invoice::constructFrom([
236+
'id' => 'in_test_'.uniqid(),
237+
'billing_reason' => Invoice::BILLING_REASON_MANUAL,
238+
'customer' => $buyer->stripe_id,
239+
'payment_intent' => 'pi_test_'.uniqid(),
240+
'currency' => 'usd',
241+
'metadata' => [
242+
'cart_id' => (string) $cart->id,
243+
'cart_item_ids' => (string) $purchasedItem->id,
244+
],
245+
'lines' => [],
246+
]);
247+
248+
(new HandleInvoicePaidJob($invoice))->handle();
249+
250+
$this->assertDatabaseHas('plugin_licenses', [
251+
'user_id' => $buyer->id,
252+
'plugin_id' => $purchasedPlugin->id,
253+
'plugin_bundle_id' => null,
254+
'price_paid' => 2500,
255+
]);
256+
257+
$this->assertDatabaseMissing('plugin_licenses', [
258+
'plugin_bundle_id' => $leftoverBundle->id,
259+
]);
260+
261+
$this->assertDatabaseMissing('plugin_licenses', [
262+
'plugin_id' => $leftoverPlugin->id,
263+
]);
264+
265+
$this->assertEquals(1, $buyer->pluginLicenses()->count());
266+
$this->assertNotNull($cart->fresh()->completed_at);
267+
}
268+
206269
public static function subscriptionPlanProvider(): array
207270
{
208271
return [

0 commit comments

Comments
 (0)