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

1 participant