Only license cart items actually paid for at checkout#406
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.paidfires,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 storedcart_idin 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
CartController): record the exact cart-item IDs that became priced Stripe line items into the session/invoice metadata ascart_item_ids.HandleInvoicePaidJob): a newresolvePurchasedItems()filters the cart down to the snapshotted items before granting any licenses. Anything else in the cart is ignored.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
HandleInvoicePaidJobTestsuite passes (8 tests); the existing receipt test exercises the no-snapshot fallback path.🤖 Generated with Claude Code