Skip to content

Only license cart items actually paid for at checkout#406

Merged
simonhamp merged 1 commit into
mainfrom
investigate-free-bundle-access
Jun 29, 2026
Merged

Only license cart items actually paid for at checkout#406
simonhamp merged 1 commit into
mainfrom
investigate-free-bundle-access

Conversation

@simonhamp

Copy link
Copy Markdown
Member

Problem

A customer reported that after buying a course via Stripe checkout, an unrelated plugin bundle that had been sitting in their cart from an earlier browsing session showed up as licensed — without being charged or receiving a receipt. Investigation confirmed this is a real bug, not a customer error.

Root cause

When invoice.paid fires, HandleInvoicePaidJob::processCartPurchase() re-read the live cart and created a license for every item currently in it, with no cross-check against what Stripe actually charged. The checkout session only stored cart_id in metadata ("we'll look up items from the cart").

This is a time-of-check / time-of-use gap: anything in the cart when the webhook runs — a leftover item, or a guest cart merged in on login — got licensed for free. The receipt reflected only the priced line items (the course), exactly matching what the customer saw.

Fix

  • Snapshot at checkout (CartController): record the exact cart-item IDs that became priced Stripe line items into the session/invoice metadata as cart_item_ids.
  • Reconcile in the webhook (HandleInvoicePaidJob): a new resolvePurchasedItems() filters the cart down to the snapshotted items before granting any licenses. Anything else in the cart is ignored.
  • Backward compatible: if the snapshot is absent (checkout sessions created before this deploy), it falls back to the previous whole-cart behavior and logs a warning.

The free-checkout path is intentionally left as-is: it runs synchronously in the same request with no payment gap and only triggers when the entire cart subtotal is $0, so it cannot mischarge.

Tests

  • New regression test: a cart holding a paid plugin and an unpaid leftover bundle, with the invoice snapshot listing only the plugin — asserts the plugin is licensed, the bundle and its plugins are not, and the cart is completed.
  • Full HandleInvoicePaidJobTest suite passes (8 tests); the existing receipt test exercises the no-snapshot fallback path.
  • Pint clean.

🤖 Generated with Claude Code

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>
@simonhamp simonhamp marked this pull request as ready for review June 29, 2026 17:50
@simonhamp simonhamp merged commit 700e2cc into main Jun 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant