🛑 Handling Refunds & Disputes in Practice
When your product gains traction, refunds and disputes become an inevitable part of the business lifecycle. Whether a customer purchased the wrong item, changed their mind, or—more alarmingly—initiated a chargeback claiming the transaction was fraudulent, your system must react swiftly, accurately, and automatically. In this chapter we will dive deep into the what, why, and how of building a resilient refund and dispute management pipeline using Vibe Coding—an iterative, developer‑centric methodology that emphasizes clear intent, rapid feedback loops, and maintainable code. By the end of this guide you will have a production‑ready solution that protects revenue, safeguards user trust, and keeps your operational overhead low.
1. Why Manual Refunds Are a Liability
What
A manual refund occurs when an operator logs into the Stripe Dashboard, locates a payment, and clicks the “Refund” button. The financial transaction is reversed in Stripe, but your application’s state remains unchanged unless you intervene.
Why
- State Drift: Your database still believes the user has an active subscription, access to premium content, or a valid license. This leads to revenue leakage (you’ve returned money but still provide service) and compliance risks (you may be violating terms of service).
- Operational Overhead: Every refund generates a support ticket, pulling engineers away from feature work.
- Error Prone: Human operators can mis‑select the wrong payment, refund the wrong amount, or forget to revoke access, creating inconsistent data that is hard to audit later.
- Financial Exposure: In the case of a chargeback, Stripe automatically debits your account and adds a $15‑$25 fee. If you are not listening for dispute webhooks, you may not notice the loss until your payout report arrives, delaying corrective action.
How (Vibe Coding Approach)
- Identify the Intent: The core intent is “when a refund is granted, the user’s entitlements must be revoked in real time.”
- Sketch the Flow: Draw a simple sequence diagram: User → Admin UI → Refund API → Stripe → Webhook → DB Update → Notification.
- Implement Incrementally: Start with the happy‑path refund API, then add webhook listeners, then layer on safety nets (idempotency, retries, alerts).
- Feedback Loop: Write automated tests that simulate a refund webhook and assert that access is revoked; run them on every commit.
- Iterate: Refine error handling, add logging, and expose metrics (refund count, dispute rate) to a dashboard for continuous improvement.
2. Proactive Refunds – Building the Refund API
What
An endpoint that your admin dashboard (or internal tool) calls to initiate a full or partial refund via Stripe’s API, followed by immediate synchronization of your internal data store.
Why
- Atomicity: By coupling the Stripe refund request with your own state change in a single request/response cycle, you guarantee that either both succeed or you can roll back cleanly.
- Auditability: The API returns the Stripe refund object, which you can persist for reconciliation and reporting.
- Scalability: The endpoint can be protected with role‑based access control (RBAC) and rate‑limited to prevent abuse.
How – Step‑by‑Step Implementation (Vibe Coding)
2.1 Project Setup
Ensure you have the Stripe Node library installed and your secret key loaded from environment variables (never hard‑code).
npm install stripe
2.2 Create the Route File
src/app/api/stripe/refund/route.ts (Next.js 13+ App Router example).
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { revokeUserAccess, logRefundEvent } from '@/lib/db'; // your DAL helpers
// Initialize Stripe with the latest stable API version
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
/**
* POST /api/stripe/refund
* Body: { paymentIntentId: string, reason?: string, amount?: number }
* - paymentIntentId: the Stripe PaymentIntent to refund
* - reason: optional Stripe refund reason (duplicate, fraudulent, requested_by_customer)
* - amount: optional integer in the smallest currency unit for partial refunds
*/
export async function POST(request: Request) {
try {
const { paymentIntentId, reason, amount } = await request.json();
// ----- Validation -----
if (!paymentIntentId) {
return NextResponse.json(
{ error: 'Missing paymentIntentId' },
{ status: 400 }
);
}
if (amount !== undefined && (typeof amount !== 'number' || amount <= 0)) {
return NextResponse.json(
{ error: 'Amount must be a positive integer in cents' },
{ status: 400 }
);
}
// ----- Build Refund Parameters -----
const refundParams: Stripe.RefundCreateParams = {
payment_intent: paymentIntentId,
reason: reason ?? 'requested_by_customer',
};
// Partial refund handling
if (amount !== undefined) {
refundParams.amount = Math.round(amount); // ensure integer cents
}
// ----- Call Stripe -----
const refund = await stripe.refunds.create(refundParams);
// ----- Synchronize Internal State -----
// For a full refund we revoke access; for partial we may adjust entitlements
const isFullRefund = amount === undefined;
if (isFullRefund) {
await revokeUserAccess(paymentIntentId);
await logRefundEvent(paymentIntentId, refund.id, 'full');
} else {
// Example: reduce remaining days on a subscription proportionally
await logRefundEvent(paymentIntentId, refund.id, `partial-${amount}`);
}
// ----- Respond -----
return NextResponse.json({
success: true,
refundId: refund.id,
status: refund.status,
amount_refunded: refund.amount,
currency: refund.currency,
});
} catch (err: any) {
// ----- Error Handling -----
console.error('[Refund API] Error:', err);
// Stripe returns specific error types; map them to HTTP codes
let status = 500;
if (err.type === 'StripeInvalidRequestError') status = 400;
else if (err.type === 'StripeAuthenticationError') status = 401;
else if (err.type === 'StripeRateLimitError') status = 429;
return NextResponse.json(
{ error: err.message ?? 'Unknown error' },
{ status }
);
}
}
2.3 Key Vibe Coding Practices Applied
| Practice | Description |
|----------|-------------|
| Explicit Intent Naming | Function and variable names (revokeUserAccess, logRefundEvent) read like sentences, making the flow self‑documenting. |
| Guard Clauses Early | Validation happens at the top, preventing deep nesting and making the happy path obvious. |
| Idempotency Consideration | Stripe refunds are idempotent by payment_intent + optional idempotency_key. You could generate a UUID from the request and pass it as idempotency_key to avoid duplicate refunds if the client retries. |
| Observability | Structured logging (console.error with context) and a dedicated logRefundEvent helper feed into your observability pipeline (e.g., Datadog, Loki). |
| Testability | The function depends on injectable helpers (revokeUserAccess, logRefundEvent). In unit tests you can mock these to verify DB calls without hitting Stripe. |
| Error Mapping | Translating Stripe error types to appropriate HTTP status codes improves client‑side handling and reduces guesswork. |
2.4 Testing the Refund API
- Unit Test – Mock
stripe.refunds.createto return a predefined refund object; assert thatrevokeUserAccessis called only for full refunds. - Integration Test – Use Stripe’s test mode with a test PaymentIntent; hit the endpoint via
supertestand verify the refund appears in the Dashboard and the DB entry is updated. - Load Test – Simulate concurrent refund requests (e.g., 50 RPS) with artillery to ensure your rate‑limiting and DB transaction handling remain stable.
3. Passive Listening – Webhooks for Refunds & Disputes
What
Stripe sends asynchronous events to a URL you configure (/api/stripe/webhook). By listening to specific event types you can react to refunds initiated elsewhere (Dashboard, mobile app, or automated processes) and, crucially, to chargebacks that can only be discovered via webhook.
Why
- Coverage: Not all refunds originate from your API; merchants may issue them directly in Stripe. Webhooks guarantee eventual consistency.
- Chargeback Visibility: Disputes (
charge.dispute.*) are only delivered via webhook; there is no polling API. Missing them means you remain unaware of a reversed transaction and its associated fees. - Decoupling: The webhook handler is independent of the refund API, allowing separate scaling, retry policies, and monitoring.
How – Detailed Webhook Handler (Vibe Coding)
We will extend the existing webhook route (src/app/api/stripe/webhook/route.ts) to handle three critical event groups:
charge.refunded– full or partial refund confirmation.charge.dispute.created– the start of a chargeback.charge.dispute.closed– the final outcome (won/lost).
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { buffer } from 'micro';
import {
revokeUserAccess,
adjustUserAccessForPartialRefund,
logDisputeEvent,
notifyAdminViaSlack
} from '@/lib/db';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// Helper to verify the webhook signature
async function getStripeEvent(req: Request): Promise<Stripe.Event> {
const sig = (await headers()).get('stripe-signature') ?? '';
const rawBody = await buffer(req);
try {
return stripe.webhooks.constructEvent(rawBody.toString(), sig, endpointSecret);
} catch (err) {
console.error('⚠️ Webhook signature verification failed.', err);
throw new Error(`Webhook Error: ${err.message}`);
}
}
export async function POST(request: Request) {
let event: Stripe.Event;
try {
event = await getStripeEvent(request);
} catch (err: any) {
return NextResponse.json(
{ error: `Webhook Error: ${err.message}` },
{ status: 400 }
);
}
// ----- Event Switch -----
switch (event.type) {
case 'charge.refunded': {
const charge = event.data.object as Stripe.Charge;
const paymentIntentId = charge.payment_intent as string;
const amountRefunded = charge.amount_refunded;
const totalAmount = charge.amount;
console.log(`💰 Charge refunded. PI: ${paymentIntentId}, Refunded: ${amountRefunded}/${totalAmount}`);
// Full vs partial logic
if (amountRefunded === totalAmount) {
console.log('🔒 Full refund detected – revoking access.');
await revokeUserAccess(paymentIntentId);
await logDisputeEvent(paymentIntentId, 'refund', 'full');
} else {
console.log(`✂️ Partial refund of ${amountRefunded} – adjusting entitlements.`);
await adjustUserAccessForPartialRefund(paymentIntentId, amountRefunded);
await logDisputeEvent(paymentIntentId, 'refund', `partial-${amountRefunded}`);
}
break;
}
case 'charge.dispute.created': {
const dispute = event.data.object as Stripe.Dispute;
const paymentIntentId = dispute.payment_intent as string;
const reason = dispute.reason; // e.g., 'fraudulent', 'product_not_received'
console.log(`🚨 Dispute created! PI: ${paymentIntentId}, Reason: ${reason}`);
// Immediate action: lock the account to prevent further service usage
await revokeUserAccess(paymentIntentId);
await logDisputeEvent(paymentIntentId, 'dispute', 'created', { reason });
// Optional: notify ops team via Slack/Email for rapid evidence gathering
await notifyAdminViaSlack(
`:warning: New dispute received!\n*PI:* ${paymentIntentId}\n*Reason:* ${reason}\n*Amount:* $${(dispute.amount / 100).toFixed(2)}`
);
break;
}
case 'charge.dispute.closed': {
const dispute = event.data.object as Stripe.Dispute;
const paymentIntentId = dispute.payment_intent as string;
const outcome = dispute.status; // 'won', 'lost', 'warning_closed', etc.
console.log(`📬 Dispute closed. PI: ${paymentIntentId}, Outcome: ${outcome}`);
if (outcome === 'won') {
console.log('🎉 We won the dispute – restoring access.');
// Depending on your business, you may restore full access or apply a probation period
await revokeUserAccess(paymentIntentId, false); // false = restore
await logDisputeEvent(paymentIntentId, 'dispute', 'won');
await notifyAdminViaSlack(
`:white_check_mark: Dispute won! PI: ${paymentIntentId}. Funds returned.`
);
} else if (outcome === 'lost') {
console.log('😭 We lost the dispute – keeping access revoked.');
await logDisputeEvent(paymentIntentId, 'dispute', 'lost');
await notifyAdminViaSlack(
`:x: Dispute lost. PI: ${paymentIntentId}. Funds withdrawn + fee.`
);
} else {
// warning_closed, etc.
await logDisputeEvent(paymentIntentId, 'dispute', outcome);
}
break;
}
default:
console.log(`ℹ️ Unhandled webhook event: ${event.type}`);
}
// Respond 2xx to acknowledge receipt
return NextResponse.json({ received: true });
}
3.1 Vibe Coding Highlights
- Clear Separation of Concerns: Each case block focuses on