Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/payments/lemonsqueezy-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,12 @@ export class LemonSqueezyProvider implements IPaymentProvider {
raw: payload,
}

// One-time order refund. Maps to `refund.succeeded`; the shared dispatcher
// flips the transaction → refunded and revokes the product entitlements
// (subscription refunds are handled by subscription_cancelled/expired).
// Order refund. Maps to `refund.succeeded`; the shared dispatcher flips the
// transaction → refunded so payouts stop counting it, and additionally
// revokes entitlements when the order was a one-time product. LS also
// raises `order_refunded` for a subscription's first order, and the
// dispatcher records the money for those too (#515) — subscription ACCESS
// stays owned by subscription_cancelled/expired.
case 'order_refunded':
return {
type: 'refund.succeeded',
Expand Down
50 changes: 35 additions & 15 deletions lib/payments/webhook-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,29 @@ export async function dispatchBillingEvent(
}

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

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

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

const { error: entErr } = await admin
.from('entitlements')
.update({ status: 'revoked', revoked_at: new Date().toISOString() })
.eq('user_id', tx.user_id)
.eq('source_type', 'product')
.eq('source_id', tx.product_id)
if (entErr) throw new Error(`dispatch ${event.type} entitlement revoke failed: ${entErr.message}`)
if (tx.product_id) {
const { error: entErr } = await admin
.from('entitlements')
.update({ status: 'revoked', revoked_at: new Date().toISOString() })
.eq('user_id', tx.user_id)
.eq('source_type', 'product')
.eq('source_id', tx.product_id)
if (entErr) throw new Error(`dispatch ${event.type} entitlement revoke failed: ${entErr.message}`)
}
break
}

Expand Down
55 changes: 54 additions & 1 deletion tests/unit/webhook-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ describe('dispatchBillingEvent', () => {
expect(calls.updates.find((u) => u.table === 'transactions')).toBeUndefined()
})

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

it('refund.succeeded on a product purchase → flips tx to refunded AND revokes the product entitlements', async () => {
const { admin, calls } = makeFakeAdmin('successful', { user_id: 'u1', product_id: 7, plan_id: null })
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
provider: PROVIDER,
admin,
})
expect(calls.updates.find((u) => u.table === 'transactions')?.values).toMatchObject({ status: 'refunded' })
expect(calls.updates.find((u) => u.table === 'entitlements')?.values).toMatchObject({ status: 'revoked' })
})

it('refund.succeeded on a SUBSCRIPTION purchase → flips tx to refunded so payouts claw it back (#515)', async () => {
// Regression for #515: a plan row (product_id null, plan_id set) used to exit
// the handler untouched, so `getPayoutsOwed()` kept counting a refunded
// subscription payment inside `grossOwed` and the school was paid its share
// of money the platform had already given back.
const { admin, calls } = makeFakeAdmin('successful', { user_id: 'u1', product_id: null, plan_id: 3 })
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
provider: PROVIDER,
admin,
})
expect(calls.updates.find((u) => u.table === 'transactions')?.values).toMatchObject({ status: 'refunded' })
// Subscription ACCESS stays owned by subscription.canceled/expired — this
// handler must not revoke entitlements for a plan row.
expect(calls.updates.find((u) => u.table === 'entitlements')).toBeUndefined()
})

it('refund.succeeded on an already-refunded tx → no writes (idempotent redelivery)', async () => {
const { admin, calls } = makeFakeAdmin('refunded', { user_id: 'u1', product_id: null, plan_id: 3 })
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
provider: PROVIDER,
admin,
})
expect(calls.updates).toHaveLength(0)
})

it('refund.succeeded on a tx with neither product_id nor plan_id → no writes', async () => {
const { admin, calls } = makeFakeAdmin('successful', { user_id: 'u1', product_id: null, plan_id: null })
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
provider: PROVIDER,
admin,
})
expect(calls.updates).toHaveLength(0)
})

it('refund.succeeded with no matching transaction row → no writes', async () => {
const { admin, calls } = makeFakeAdmin(null)
await dispatchBillingEvent(event('refund.succeeded', { reference: '42', providerPaymentId: 'pi_1' }), {
provider: PROVIDER,
admin,
})
expect(calls.updates).toHaveLength(0)
})

it('payment.failed with reference → flips the abandoned pending tx to failed (frees retry, #479)', async () => {
// Binance PAY_CLOSED on an abandoned checkout: the pending transaction must
// be cleared or transactions_unique_product/plan blocks the buyer's retry.
Expand Down
Loading