When charge.refunded webhooks go unhandled, the application keeps granting paid features to users who have already been refunded — a direct revenue leak and a fraud vector where attackers chargeback a subscription then continue using the product indefinitely. Missing refund workflows also violate consumer-protection requirements in jurisdictions with mandatory refund windows (EU distance selling, California auto-renewal laws), exposing the business to regulatory complaints and chargeback disputes.
Medium because refunded users retain paid access, creating ongoing revenue leakage and chargeback-fraud exposure across the subscriber base.
Handle charge.refunded in the Stripe webhook route and revoke access when the full amount is refunded. In app/api/webhooks/stripe/route.ts, add a case that compares charge.amount_refunded === charge.amount and updates the user record to downgrade the plan:
case 'charge.refunded':
if (charge.amount_refunded === charge.amount) {
await db.user.update({ where: { stripe_customer_id: charge.customer }, data: { plan: 'free' } })
}
break
ID: saas-billing.subscription-mgmt.refund-flow
Severity: medium
What to look for: Determine whether refund handling exists in the application. Look for Stripe Customer Portal (which includes refund-adjacent self-service features), admin interface for initiating refunds (stripe.refunds.create()), or documented support process. Check whether the application handles the charge.refunded webhook to update any local state (like reverting premium access or crediting usage). For most small SaaS applications, refunds are processed manually via Stripe Dashboard — this is acceptable if it's intentional.
Pass criteria: Count every refund-related handler and interface. At least 1 of: (a) Stripe Customer Portal is configured and users can manage subscriptions there, (b) there is an admin interface or support workflow for processing refunds, OR (c) the refund policy is documented and refunds are processed manually through Stripe Dashboard. The charge.refunded webhook is handled if it affects application state (e.g., reverting access).
Fail criteria: No refund mechanism exists and the application has no documented refund process. charge.refunded webhook events are not handled but should affect application state (e.g., access should be revoked on refund but isn't).
Skip (N/A) when: No payment processing detected.
Detail on fail: "No refund handling in webhook handlers and no admin refund interface — charge.refunded events are ignored despite affecting subscription state" or "Refund reverts payment in Stripe but application continues to grant premium access because charge.refunded is unhandled"
Remediation: At minimum, handle charge.refunded to update application state:
case 'charge.refunded': {
const charge = event.data.object
// If fully refunded, consider revoking access
if (charge.refunded && charge.amount_refunded === charge.amount) {
await db.user.update({
where: { stripe_customer_id: charge.customer as string },
data: { plan: 'free', subscription_status: 'canceled' }
})
}
break
}
For a complete self-service experience, integrate Stripe Customer Portal — it handles cancellations and gives users a managed refund request flow.