Skip to content

Commit 95a4abf

Browse files
fix(payments): claw back refunded subscription payments (#515)
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
1 parent 155a883 commit 95a4abf

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

lib/payments/lemonsqueezy-provider.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,12 @@ export class LemonSqueezyProvider implements IPaymentProvider {
300300
raw: payload,
301301
}
302302

303-
// One-time order refund. Maps to `refund.succeeded`; the shared dispatcher
304-
// flips the transaction → refunded and revokes the product entitlements
305-
// (subscription refunds are handled by subscription_cancelled/expired).
303+
// Order refund. Maps to `refund.succeeded`; the shared dispatcher flips the
304+
// transaction → refunded so payouts stop counting it, and additionally
305+
// revokes entitlements when the order was a one-time product. LS also
306+
// raises `order_refunded` for a subscription's first order, and the
307+
// dispatcher records the money for those too (#515) — subscription ACCESS
308+
// stays owned by subscription_cancelled/expired.
306309
case 'order_refunded':
307310
return {
308311
type: 'refund.succeeded',

lib/payments/webhook-dispatch.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,29 @@ export async function dispatchBillingEvent(
212212
}
213213

214214
case 'refund.succeeded': {
215-
// One-time order refund (Lemon Squeezy `order_refunded`). Mirrors the
216-
// Stripe Connect route's charge.refunded product path: flip → refunded and
217-
// EXPLICITLY revoke the product entitlements — no trigger does this for
218-
// products (trigger_manage_transactions only acts on successful/failed).
219-
// Subscription refunds are owned by subscription.canceled/expired, so skip
220-
// when plan_id is set. Status-guarded for idempotency.
215+
// A refund on a completed purchase — one-time product OR subscription
216+
// (Lemon Squeezy `order_refunded`, PayPal `PAYMENT.CAPTURE.REFUNDED`,
217+
// Binance `PAY_REFUND`/`REFUND_SUCCESS`). Two separate decisions here,
218+
// which used to be conflated into a single product-only guard (#515):
219+
//
220+
// 1. RECORD THE MONEY. `transactions.status` → 'refunded' for both kinds.
221+
// This is what `getPayoutsOwed()` reads: `computeOwedBalances` leaves
222+
// refunded sales out of `grossOwed`, so the school is no longer owed
223+
// a share of money the platform gave back. Skipping this for plan
224+
// rows meant a refunded subscription was never clawed back on the
225+
// platform-settled providers (#498 follow-up). The legacy Stripe
226+
// Connect route already flips both kinds (`charge.refunded`).
227+
//
228+
// 2. REVOKE ACCESS. Products only, unchanged. No trigger revokes product
229+
// entitlements (trigger_manage_transactions acts on
230+
// successful/failed), so it is done explicitly here. Subscription
231+
// access stays owned by subscription.canceled/expired, which write
232+
// `subscriptions.subscription_status` and let the DB trigger cascade.
233+
//
234+
// Flipping a plan row to 'refunded' is inert in the DB — the trigger
235+
// matches neither branch — and drops the row out of the partial unique
236+
// index transactions_unique_plan, which frees the buyer to re-subscribe
237+
// to that plan later. Status-guarded for idempotency on redelivery.
221238
if (!event.reference) break
222239
const txnId = Number.parseInt(event.reference, 10)
223240
if (Number.isNaN(txnId)) break
@@ -228,8 +245,9 @@ export async function dispatchBillingEvent(
228245
.eq('transaction_id', txnId)
229246
.maybeSingle()
230247

231-
// Only act on a completed one-time product purchase.
232-
if (!tx || tx.plan_id || !tx.product_id || tx.status !== 'successful') break
248+
// Only act on a completed purchase of one of the two kinds.
249+
if (!tx || tx.status !== 'successful') break
250+
if (!tx.product_id && !tx.plan_id) break
233251

234252
const { error: refErr } = await admin
235253
.from('transactions')
@@ -238,13 +256,15 @@ export async function dispatchBillingEvent(
238256
.eq('status', 'successful')
239257
if (refErr) throw new Error(`dispatch ${event.type} failed: ${refErr.message}`)
240258

241-
const { error: entErr } = await admin
242-
.from('entitlements')
243-
.update({ status: 'revoked', revoked_at: new Date().toISOString() })
244-
.eq('user_id', tx.user_id)
245-
.eq('source_type', 'product')
246-
.eq('source_id', tx.product_id)
247-
if (entErr) throw new Error(`dispatch ${event.type} entitlement revoke failed: ${entErr.message}`)
259+
if (tx.product_id) {
260+
const { error: entErr } = await admin
261+
.from('entitlements')
262+
.update({ status: 'revoked', revoked_at: new Date().toISOString() })
263+
.eq('user_id', tx.user_id)
264+
.eq('source_type', 'product')
265+
.eq('source_id', tx.product_id)
266+
if (entErr) throw new Error(`dispatch ${event.type} entitlement revoke failed: ${entErr.message}`)
267+
}
248268
break
249269
}
250270

tests/unit/webhook-dispatch.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ describe('dispatchBillingEvent', () => {
259259
expect(calls.updates.find((u) => u.table === 'transactions')).toBeUndefined()
260260
})
261261

262-
it('refund.succeeded → no writes', async () => {
262+
it('refund.succeeded without a reference → no writes', async () => {
263263
const { admin, calls } = makeFakeAdmin()
264264
await dispatchBillingEvent(event('refund.succeeded', { providerPaymentId: 'pi_1' }), {
265265
provider: PROVIDER,
@@ -269,6 +269,59 @@ describe('dispatchBillingEvent', () => {
269269
expect(calls.rpc).toHaveLength(0)
270270
})
271271

272+
it('refund.succeeded on a product purchase → flips tx to refunded AND revokes the product entitlements', async () => {
273+
const { admin, calls } = makeFakeAdmin('successful', { user_id: 'u1', product_id: 7, plan_id: null })
274+
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
275+
provider: PROVIDER,
276+
admin,
277+
})
278+
expect(calls.updates.find((u) => u.table === 'transactions')?.values).toMatchObject({ status: 'refunded' })
279+
expect(calls.updates.find((u) => u.table === 'entitlements')?.values).toMatchObject({ status: 'revoked' })
280+
})
281+
282+
it('refund.succeeded on a SUBSCRIPTION purchase → flips tx to refunded so payouts claw it back (#515)', async () => {
283+
// Regression for #515: a plan row (product_id null, plan_id set) used to exit
284+
// the handler untouched, so `getPayoutsOwed()` kept counting a refunded
285+
// subscription payment inside `grossOwed` and the school was paid its share
286+
// of money the platform had already given back.
287+
const { admin, calls } = makeFakeAdmin('successful', { user_id: 'u1', product_id: null, plan_id: 3 })
288+
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
289+
provider: PROVIDER,
290+
admin,
291+
})
292+
expect(calls.updates.find((u) => u.table === 'transactions')?.values).toMatchObject({ status: 'refunded' })
293+
// Subscription ACCESS stays owned by subscription.canceled/expired — this
294+
// handler must not revoke entitlements for a plan row.
295+
expect(calls.updates.find((u) => u.table === 'entitlements')).toBeUndefined()
296+
})
297+
298+
it('refund.succeeded on an already-refunded tx → no writes (idempotent redelivery)', async () => {
299+
const { admin, calls } = makeFakeAdmin('refunded', { user_id: 'u1', product_id: null, plan_id: 3 })
300+
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
301+
provider: PROVIDER,
302+
admin,
303+
})
304+
expect(calls.updates).toHaveLength(0)
305+
})
306+
307+
it('refund.succeeded on a tx with neither product_id nor plan_id → no writes', async () => {
308+
const { admin, calls } = makeFakeAdmin('successful', { user_id: 'u1', product_id: null, plan_id: null })
309+
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
310+
provider: PROVIDER,
311+
admin,
312+
})
313+
expect(calls.updates).toHaveLength(0)
314+
})
315+
316+
it('refund.succeeded with no matching transaction row → no writes', async () => {
317+
const { admin, calls } = makeFakeAdmin(null)
318+
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
319+
provider: PROVIDER,
320+
admin,
321+
})
322+
expect(calls.updates).toHaveLength(0)
323+
})
324+
272325
it('payment.failed with reference → flips the abandoned pending tx to failed (frees retry, #479)', async () => {
273326
// Binance PAY_CLOSED on an abandoned checkout: the pending transaction must
274327
// be cleared or transactions_unique_product/plan blocks the buyer's retry.

0 commit comments

Comments
 (0)