fix(payments): claw back refunded subscription payments (#515) - #522
Merged
Conversation
The shared `refund.succeeded` handler required a one-time product purchase (`if (!tx || tx.plan_id || !tx.product_id || tx.status !== 'successful') break`), so a refund resolving to a subscription transaction exited without writing anything. Nothing else writes `transactions.status = 'refunded'` for a plan row on the unified webhook path, so `getPayoutsOwed()` kept counting the sale inside `grossOwed` and the school was still paid its share of money the platform had already given back. Split the guard into the two decisions it was conflating: - Record the money for BOTH kinds — flip `transactions.status` to `refunded`, status-guarded so a webhook redelivery is a no-op. This is what the payout computation reads. - Revoke access for products ONLY, unchanged. Subscription access stays owned by `subscription.canceled`/`subscription.expired`. Verified against the local database that flipping a plan row is inert: the `after_transaction_update` trigger branches on `successful`/`failed` only, so `subscriptions` and `entitlements` are untouched, and the row leaving the partial unique index `transactions_unique_plan` frees the buyer to re-subscribe to that plan later. Adds five `refund.succeeded` cases to `tests/unit/webhook-dispatch.test.ts`, including the #515 regression (which fails against the previous guard) and a product case pinning the unchanged entitlement-revoke path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZ7dtT3dqVphasQxZLFBaZ
guillermoscript
marked this pull request as ready for review
July 25, 2026 02:48
Closed
25 tasks
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.
Closes #515.
Description
The shared
refund.succeededhandler inlib/payments/webhook-dispatch.tsrequired a one-time product purchase before it would do anything:Two of those conditions exclude subscriptions —
tx.plan_idtruthy exits, and a plan transaction hasproduct_id IS NULLso!tx.product_idexits as well. A refund event whosereferenceresolved to a subscription transaction therefore returned without writing anything.The guard's comment ("subscription refunds are owned by
subscription.canceled/expired") was accurate about entitlements, but those handlers only writesubscriptions.subscription_statusandended_at. Nothing on the unified webhook path ever wrotetransactions.status = 'refunded'for a plan row.All three platform-settled providers do deliver the event with a usable
reference, so it arrived and was dropped downstream:lib/payments/paypal-provider.ts—PAYMENT.CAPTURE.REFUNDEDlib/payments/binance-provider.ts—PAY_REFUND/REFUND_SUCCESSlib/payments/lemonsqueezy-provider.ts—order_refunded(Lemon Squeezy raises this for a subscription's first order too)The legacy Stripe Connect route was already the counter-example:
app/api/stripe/webhook/route.ts(charge.refunded) flips the transaction for product and plan rows alike. The unified dispatcher was the outlier.Impact
getPayoutsOwed()selects transactions withstatus IN ('successful','refunded')andcomputeOwedBalancesleaves refunded sales out ofgrossOwed. A subscription refund that never reachedrefundedtherefore stayed insidegrossOwed: the platform kept showing the school's share of a payment it had already given back, and paid it out on the next cycle.clawbackunder-reported for the same reason, since it is derived from the same refunded rows.Note that the issue was filed before #511 (PR #521) changed the payout arithmetic. The bug is unchanged, but the mechanism is now the
grossOwedexclusion rather than aclawbacksubtraction — this PR is written against currentmaster.Changes made
lib/payments/webhook-dispatch.ts— split the guard into the two decisions it was conflating:transactions.status→refundedfor product and plan rows, keeping the.eq('status','successful')guard so a webhook redelivery is a no-op. This is the value the payout computation reads.trigger_manage_transactionsacts onsuccessful/failedonly), so it stays explicit here. Subscription access remains owned bysubscription.canceled/subscription.expired, exactly as before.Rows in a status other than
successful, and rows with neitherproduct_idnorplan_id, still break out without writing.refundedis an existing value of thetransaction_statusenum and is already written by the legacy Stripe route, so no migration is needed.lib/payments/lemonsqueezy-provider.ts— comment only. Theorder_refundeddocblock claimed subscription refunds were handled entirely bysubscription_cancelled/expired, which is now only true of access, not of the money.tests/unit/webhook-dispatch.test.ts— fiverefund.succeededcases replacing the single "no writes" case.Type of change
Testing
npm run test:unit— 318 passed, 27 files, 0 failuresnpx vitest run tests/unit/webhook-dispatch.test.ts tests/unit/payouts-owed.test.ts— 48 passed (22 dispatcher + 26 payout-arithmetic; the Payout clawback double-subtracts refunds and claws back sales never paid out (#498 follow-up) #511 arithmetic is untouched by this change)npm run typecheck— clean, no errorsnpm run build— production build succeedednpx eslinton the three changed files — 0 errors. 13 warnings, all pre-existingno-unused-varson underscore-prefixed interface stubs inlemonsqueezy-provider.tsand the test's_colsparameter; none on changed lines.The regression test was confirmed to fail against the old code
The new
#515case is not a test that would pass either way. With the previous guard restored in place, the suite reports exactly one failure — that case — and passes again once the fix is reapplied:Live database verification
The unit tests use a fake Supabase client, so the one claim they cannot prove is what the database does when a plan row flips to
refunded. Verified directly against the local database inside a transaction that was rolled back:UPDATE transactions SET status='refunded'on a plan rowUPDATE 1— no trigger errorsubscriptions.subscription_statusafterwardsactive)entitlementsfor that user afterwardsactive)pendingrow insertsThe first three confirm the flip is inert:
after_transaction_update→trigger_manage_transactionsbranches onstatus = 'successful'andstatus = 'failed', and matches neither. The fourth is a welcome side effect — the row leaves the partial unique indextransactions_unique_plan(WHERE product_id IS NULL AND status IN ('pending','successful')), so a refunded student can re-subscribe to that plan. Previously the refunded-but-still-successfulrow blocked that purchase permanently. The product path already behaved this way.QA verification steps
No UI changed in this PR, so there are no screenshots. To verify end to end:
payment_providerinpaypal,binanceorlemonsqueezy— withplan_idset,product_idnull, andstatus = 'successful'. It also needsschool_percentage_snapshotset for the payout figures to be meaningful./platform/payouts. Note the school'sgrossOwedandnetOwedfor that currency./api/payments/webhook/<provider>, with the transaction id round-tripped asreference(custom_idfor PayPal,merchantTradeNo/passthrough for Binance,custom_datafor Lemon Squeezy).status = 'refunded'./platform/payouts— the school's share of that sale is gone fromgrossOwed, andnetOweddrops by the same amount.subscriptionsrow andentitlementsare unchanged — this PR deliberately does not touch access.status = 'successful'guard makes it a no-op).Known limitations (deliberate, not regressions)
Both are pre-existing and unchanged by this PR; recording them so a reviewer does not have to rediscover them:
subscription.canceled/expired. A refund arriving without an accompanying cancel event books the refund while the student keeps access until the period ends. This PR fixes the money side only.transactions_unique_planblocks it;extend_subscription_periodextends the existing row instead), so a refund of a renewal resolves to the original first-charge transaction and flips that one. The amount is correct for a fixed-price plan, but the row it lands on is the first charge. Fixing this properly needs per-period transaction rows and is out of scope here.Checklist
payment.succeeded/subscription.activatedare untouched)dispatch <event>prefix)