Skip to content

Stop /course licensing the whole cart and refund plugins one at a time - #547

Merged
simonhamp merged 2 commits into
mainfrom
course-checkout-cart-leak
Oct 2, 2026
Merged

simonhamp merged 2 commits into
mainfrom
course-checkout-cart-leak

Conversation

@simonhamp

Copy link
Copy Markdown
Member

This fixes two money bugs in checkout. They are separate commits and touch different files.

Buying the Masterclass licensed the buyer's whole cart

The /course checkout added the Masterclass to the buyer's cart and sent Stripe only the cart ID. When the invoice was paid, HandleInvoicePaidJob found no list of purchased items and fell back to licensing everything in the cart. Any plugin or bundle sitting in the cart was licensed for free, and third-party developers got a payout for it. #406 was meant to fix this but only added the purchased item list to the cart checkout, not to the course one.

The course is now a direct purchase that never touches the cart:

  • The checkout sends Stripe the product ID instead of a cart ID.
  • The job has a new branch that licenses that one product from the invoice.
  • price_paid is the invoice total, so a subscriber's discounted purchase is recorded at what they paid rather than the list price.
  • If an earlier checkout left the Masterclass in the buyer's cart, it is removed once they have paid, so the cart can't charge for it again.
  • The checkout returns a 404 if the Masterclass is inactive. Adding it to the cart used to enforce that.

Refunding one plugin refunded the whole payment

RefundPluginPurchase passed Stripe only the payment intent, so Stripe refunded everything bought in that checkout while only the one license was revoked.

It now refunds the license's own line of the checkout, at Stripe's total for that line after coupons and tax:

  • The cart checkout tags each line with its plugin or bundle ID. The refund finds the line by that ID, or by name for checkouts made before the tagging.
  • A bundle is one line, so it is still one refund that revokes every license in the bundle.
  • If no line matches, or nothing was charged for it, the refund is refused.
  • The line item is the idempotency key, so a double submit or a quick retry gets the same refund back. Stripe keeps keys for about a day.
  • The confirmation modal no longer quotes the list price. The success message shows the amount refunded.

Things to watch

  • The webhook change has to be live before anyone pays through the new course checkout. A queue worker still running the old code would take the payment and license nothing.
  • Course checkouts opened before the deploy still carry only a cart ID, so they behave the old way until they expire (24 hours at most).
  • Licenses and payouts the course bug already created are not touched.
  • Tests mock Stripe. The line item lookup was checked against the Stripe test account (read-only), but no real refund has been sent. Worth trying one in the sandbox on a multi-item checkout.

The full test suite passes locally (2017 passed, 1 skipped).

🤖 Generated with Claude Code

simonhamp and others added 2 commits October 2, 2026 11:16
The /course checkout added the Masterclass to the buyer's cart and sent
Stripe only the cart ID. When the invoice was paid, the webhook job
licensed everything in that cart, so plugins and bundles the buyer had
not paid for were licensed and their developers got a payout.

The course is now a direct purchase. The checkout sends the product ID
and never touches the cart, and the job licenses that one product from
the invoice. If an earlier checkout left the Masterclass in the buyer's
cart, it is removed once they have paid so the cart cannot charge for
it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refunding a plugin license refunded the whole payment, so every other
item bought in the same checkout was refunded too while only the one
license was revoked.

The refund now covers the license's own line of the Stripe checkout, at
Stripe's total for that line after coupons and tax. The cart checkout
tags each line with its plugin or bundle ID so the line can be found.
Checkouts made before that are matched by name. The line item is the
idempotency key, so a repeated request gets the same refund back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp marked this pull request as ready for review October 2, 2026 11:05
@simonhamp
simonhamp merged commit 19e2015 into main Oct 2, 2026
3 checks passed
@simonhamp
simonhamp deleted the course-checkout-cart-leak branch October 2, 2026 11:05
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