osmoto.
Case StudiesBlogBook Consultation

Services

Stripe IntegrationSubscription BillingPayment Automation & AINext.js OptimizationAudit & Fix

Solutions

For FoundersFor SaaS CompaniesFor E-Commerce StoresFor Marketplaces

Resources

Implementation GuideWebhook Best PracticesPCI Compliance GuideStripe vs Alternatives
Case StudiesBlog
Book Consultation
osmoto.

Professional Stripe integration services

Services

  • Stripe Integration
  • Subscription Billing
  • E-Commerce Integration
  • Next.js Optimization
  • Audit & Fix

Solutions

  • For Founders
  • For SaaS
  • For E-Commerce
  • For Marketplaces
  • Integration as a Service

Resources

  • Implementation Guide
  • Webhook Guide
  • PCI Compliance
  • Stripe vs Alternatives

Company

  • About
  • Case Studies
  • Process
  • Pricing
  • Contact
© 2026 Osmoto · Professional Stripe Integration Services
Back to Blog
E-Commerce17 min read

Handling Partial Refunds and Order Modifications in Stripe

Your customer just ordered three items but changed their mind about one. Or they received a damaged product and want a partial refund. Or they're disputing a ch...

Osmoto Team

Senior Software Engineer

February 19, 2026
Handling Partial Refunds and Order Modifications in Stripe

Your customer just ordered three items but changed their mind about one. Or they received a damaged product and want a partial refund. Or they're disputing a charge for part of their order. These scenarios happen daily in e-commerce, and how you handle them directly impacts customer satisfaction, operational efficiency, and your bottom line.

The challenge isn't just processing the refund—it's maintaining data consistency across your order management system, inventory, accounting records, and customer communications. A poorly implemented refund workflow can lead to inventory discrepancies, accounting nightmares, and confused customers who receive conflicting information about their order status. I've seen businesses lose thousands of dollars because their partial refund logic didn't properly update order totals or trigger the right downstream processes.

In this post, I'll walk through implementing robust partial refund and order modification workflows using Stripe, covering the technical implementation, edge cases that will bite you in production, and architectural patterns that scale with your business. We'll look at real code examples, discuss idempotency considerations, and explore how to maintain consistency across your entire system.

Understanding Stripe's Refund Model

Before diving into implementation, you need to understand how Stripe handles refunds at the API level. This isn't just academic—misunderstanding Stripe's refund model leads to bugs that only surface when customers request refunds.

Payment Intents vs Charges

Stripe has two refund paths depending on your integration:

Payment Intents (modern approach): When you create a PaymentIntent, Stripe creates a Charge object behind the scenes once payment succeeds. You can refund either by referencing the PaymentIntent ID or the underlying Charge ID. The PaymentIntent maintains a amount_refunded field that automatically updates.

Charges (legacy approach): Direct Charge creation without PaymentIntents. Still supported, but lacks the sophisticated payment flow handling of PaymentIntents.

Here's the critical distinction: refunds are always applied to Charges, not PaymentIntents. When you issue a refund, you're creating a Refund object linked to a Charge. The PaymentIntent is just a convenient wrapper.

// Refunding via PaymentIntent (recommended) const refund = await stripe.refunds.create({ payment_intent: 'pi_xxx', amount: 500, // $5.00 in cents metadata: { order_id: 'order_123', reason: 'damaged_item', item_id: 'item_456' } }); // Refunding via Charge (legacy, but still valid) const refund = await stripe.refunds.create({ charge: 'ch_xxx', amount: 500 });

Partial vs Full Refunds

Stripe distinguishes between these at the API level:

  • Full refund: Omit the amount parameter or pass the full charge amount
  • Partial refund: Specify an amount less than the charge total

You can issue multiple partial refunds until you've refunded the entire amount. Stripe tracks this automatically:

const paymentIntent = await stripe.paymentIntents.retrieve('pi_xxx'); console.log(paymentIntent.amount); // Original: 10000 ($100.00) console.log(paymentIntent.amount_refunded); // Currently refunded: 2500 ($25.00) console.log(paymentIntent.amount - paymentIntent.amount_refunded); // Remaining: 7500 ($75.00)

Critical detail: Stripe calculates the maximum refundable amount as amount - amount_refunded - amount_captured_but_not_yet_refunded. If you have multiple refund requests in flight, you can accidentally over-refund. This is where idempotency becomes essential.

Implementing Partial Refunds with Order Context

The real challenge isn't calling stripe.refunds.create()—it's maintaining consistency between Stripe's refund state and your order data. Here's a production-ready implementation that handles this properly.

Database Schema Considerations

First, structure your data to support partial refunds:

// Order model interface Order { id: string; stripe_payment_intent_id: string; total_amount: number; // In cents refunded_amount: number; // Track total refunded status: 'pending' | 'paid' | 'partially_refunded' | 'fully_refunded' | 'cancelled'; items: OrderItem[]; refunds: OrderRefund[]; created_at: Date; updated_at: Date; } // Order item model interface OrderItem { id: string; order_id: string; product_id: string; quantity: number; unit_price: number; total_price: number; refunded_quantity: number; // Track refunded items refunded_amount: number; status: 'active' | 'partially_refunded' | 'fully_refunded'; } // Refund record model interface OrderRefund { id: string; order_id: string; stripe_refund_id: string; amount: number; reason: string; items: RefundedItem[]; // Which items were refunded status: 'pending' | 'succeeded' | 'failed' | 'cancelled'; created_at: Date; processed_at?: Date; } interface RefundedItem { item_id: string; quantity: number; amount: number; }

This schema lets you track exactly which items were refunded, how much was refunded per item, and maintain an audit trail. The refunded_quantity field is crucial for inventory management.

Building the Refund Service

Here's a complete refund service that handles the complexity:

import Stripe from 'stripe'; import { db } from './database'; import { sendCustomerEmail } from './email-service'; import { adjustInventory } from './inventory-service'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', }); interface RefundItemRequest { item_id: string; quantity: number; reason: string; } interface RefundRequest { order_id: string; items: RefundItemRequest[]; reason: string; notify_customer?: boolean; idempotency_key?: string; } export class OrderRefundService { async processPartialRefund(request: RefundRequest): Promise<OrderRefund> { // Start transaction to ensure atomicity return await db.transaction(async (trx) => { // 1. Fetch order with lock to prevent concurrent modifications const order = await trx('orders') .where({ id: request.order_id }) .forUpdate() .first(); if (!order) { throw new Error('Order not found'); } if (!order.stripe_payment_intent_id) { throw new Error('Order has no payment intent'); } // 2. Fetch order items with lock const orderItems = await trx('order_items') .where({ order_id: order.id }) .forUpdate(); // 3. Calculate refund amount and validate const { refundAmount, refundedItems } = this.calculateRefundAmount( orderItems, request.items ); if (refundAmount <= 0) { throw new Error('Refund amount must be greater than 0'); } // 4. Check if refund would exceed order total const totalRefunded = order.refunded_amount + refundAmount; if (totalRefunded > order.total_amount) { throw new Error( `Refund amount ($${refundAmount / 100}) would exceed order total ($${order.total_amount / 100})` ); } // 5. Create refund record in pending state const [refundRecord] = await trx('order_refunds').insert({ order_id: order.id, amount: refundAmount, reason: request.reason, items: JSON.stringify(refundedItems), status: 'pending', created_at: new Date(), }).returning('*'); try { // 6. Process Stripe refund with idempotency const stripeRefund = await stripe.refunds.create( { payment_intent: order.stripe_payment_intent_id, amount: refundAmount, metadata: { order_id: order.id, refund_record_id: refundRecord.id, reason: request.reason, }, reason: this.mapReasonToStripe(request.reason), }, { idempotencyKey: request.idempotency_key || `refund_${order.id}_${refundRecord.id}`, } ); // 7. Update refund record with Stripe ID await trx('order_refunds') .where({ id: refundRecord.id }) .update({ stripe_refund_id: stripeRefund.id, status: 'succeeded', processed_at: new Date(), }); // 8. Update order items for (const refundedItem of refundedItems) { await trx('order_items') .where({ id: refundedItem.item_id }) .increment('refunded_quantity', refundedItem.quantity) .increment('refunded_amount', refundedItem.amount); // Update item status const item = orderItems.find(i => i.id === refundedItem.item_id); if (item) { const newRefundedQty = item.refunded_quantity + refundedItem.quantity; const newStatus = newRefundedQty >= item.quantity ? 'fully_refunded' : 'partially_refunded'; await trx('order_items') .where({ id: refundedItem.item_id }) .update({ status: newStatus }); } } // 9. Update order totals and status await trx('orders') .where({ id: order.id }) .increment('refunded_amount', refundAmount) .update({ status: totalRefunded >= order.total_amount ? 'fully_refunded' : 'partially_refunded', updated_at: new Date(), }); // 10. Adjust inventory (outside transaction for performance) setImmediate(() => { this.handleInventoryAdjustment(order.id, refundedItems); }); // 11. Send customer notification if (request.notify_customer) { setImmediate(() => { this.notifyCustomer(order, refundAmount, refundedItems); }); } return refundRecord; } catch (error) { // Mark refund as failed await trx('order_refunds') .where({ id: refundRecord.id }) .update({ status: 'failed', error_message: error.message, }); throw error; } }); } private calculateRefundAmount( orderItems: OrderItem[], requestedItems: RefundItemRequest[] ): { refundAmount: number; refundedItems: RefundedItem[] } { let refundAmount = 0; const refundedItems: RefundedItem[] = []; for (const requested of requestedItems) { const orderItem = orderItems.find(i => i.id === requested.item_id); if (!orderItem) { throw new Error(`Order item ${requested.item_id} not found`); } // Validate refund quantity const remainingQuantity = orderItem.quantity - orderItem.refunded_quantity; if (requested.quantity > remainingQuantity) { throw new Error( `Cannot refund ${requested.quantity} of item ${orderItem.id}. ` + `Only ${remainingQuantity} remaining (${orderItem.refunded_quantity} already refunded)` ); } // Calculate proportional refund amount const itemRefundAmount = Math.round( (orderItem.unit_price * requested.quantity) ); refundAmount += itemRefundAmount; refundedItems.push({ item_id: orderItem.id, quantity: requested.quantity, amount: itemRefundAmount, }); } return { refundAmount, refundedItems }; } private mapReasonToStripe(reason: string): Stripe.RefundCreateParams.Reason { const reasonMap: Record<string, Stripe.RefundCreateParams.Reason> = { 'duplicate': 'duplicate', 'fraudulent': 'fraudulent', 'customer_request': 'requested_by_customer', }; return reasonMap[reason] || 'requested_by_customer'; } private async handleInventoryAdjustment( orderId: string, refundedItems: RefundedItem[] ): Promise<void> { try { for (const item of refundedItems) { await adjustInventory({ item_id: item.item_id, quantity: item.quantity, reason: 'refund', order_id: orderId, }); } } catch (error) { // Log error but don't fail the refund console.error('Inventory adjustment failed:', error); // Consider adding to a retry queue } } private async notifyCustomer( order: Order, refundAmount: number, refundedItems: RefundedItem[] ): Promise<void> { try { await sendCustomerEmail({ to: order.customer_email, template: 'refund_processed', data: { order_id: order.id, refund_amount: refundAmount / 100, refunded_items: refundedItems, }, }); } catch (error) { console.error('Customer notification failed:', error); } } }

This implementation handles several critical aspects:

  1. Database transactions ensure atomicity—either everything succeeds or nothing changes
  2. Row locking prevents concurrent refund requests from over-refunding
  3. Idempotency keys prevent duplicate Stripe refunds if the request is retried
  4. Proportional calculations ensure accurate per-item refund amounts
  5. Status tracking at both order and item levels
  6. Async side effects (inventory, emails) don't block the refund transaction

Handling Webhooks for Refund Confirmation

Stripe sends webhooks for refund events, and you must handle them properly to maintain consistency. This is especially important for refunds initiated outside your application (e.g., through the Stripe Dashboard).

import { Stripe } from 'stripe'; import { db } from './database'; export async function handleRefundWebhook( event: Stripe.Event ): Promise<void> { switch (event.type) { case 'charge.refunded': await handleChargeRefunded(event.data.object as Stripe.Charge); break; case 'charge.refund.updated': await handleRefundUpdated(event.data.object as Stripe.Refund); break; default: console.log(`Unhandled refund event type: ${event.type}`); } } async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> { // Find order by payment intent const order = await db('orders') .where({ stripe_payment_intent_id: charge.payment_intent }) .first(); if (!order) { console.warn(`No order found for charge ${charge.id}`); return; } // Check if this is a new refund or an update const existingRefund = await db('order_refunds') .where({ stripe_refund_id: charge.refunds?.data[0]?.id }) .first(); if (existingRefund) { // Update existing refund record await db('order_refunds') .where({ id: existingRefund.id }) .update({ status: 'succeeded', processed_at: new Date(), }); } else { // This refund was initiated outside our system (e.g., Stripe Dashboard) await handleExternalRefund(order, charge); } // Always sync the order's refunded amount with Stripe's source of truth await syncOrderRefundAmount(order.id, charge.amount_refunded); } async function handleExternalRefund( order: Order, charge: Stripe.Charge ): Promise<void> { const latestRefund = charge.refunds?.data[0]; if (!latestRefund) return; await db.transaction(async (trx) => { // Create refund record await trx('order_refunds').insert({ order_id: order.id, stripe_refund_id: latestRefund.id, amount: latestRefund.amount, reason: 'external_refund', status: 'succeeded', created_at: new Date(latestRefund.created * 1000), processed_at: new Date(), metadata: JSON.stringify({ source: 'stripe_dashboard', stripe_reason: latestRefund.reason, }), }); // Update order status const totalRefunded = charge.amount_refunded; await trx('orders') .where({ id: order.id }) .update({ refunded_amount: totalRefunded, status: totalRefunded >= order.total_amount ? 'fully_refunded' : 'partially_refunded', }); // Alert admin about external refund console.warn( `External refund detected for order ${order.id}: $${latestRefund.amount / 100}` ); }); } async function syncOrderRefundAmount( orderId: string, stripeRefundedAmount: number ): Promise<void> { await db('orders') .where({ id: orderId }) .update({ refunded_amount: stripeRefundedAmount, updated_at: new Date(), }); }

Key webhook considerations:

  • charge.refunded fires when a refund is created or updated. Handle both scenarios.
  • External refunds (initiated via Dashboard) won't have corresponding records in your database initially
  • Idempotency is critical—webhooks can be delivered multiple times
  • Sync refunded amounts from Stripe as the source of truth, not just your calculations

For more details on robust webhook handling, see our Webhook Implementation Guide.

Order Modification Patterns

Sometimes customers need to modify orders before or after payment—changing quantities, swapping items, or adding products. These scenarios require different approaches than simple refunds.

Pre-Payment Modifications

For orders not yet paid, you can simply update the PaymentIntent amount:

async function modifyUnpaidOrder( orderId: string, modifications: OrderModification[] ): Promise<void> { await db.transaction(async (trx) => { const order = await trx('orders') .where({ id: orderId }) .forUpdate() .first(); if (order.status !== 'pending') { throw new Error('Can only modify unpaid orders'); } // Apply modifications and calculate new total const newTotal = await this.applyModifications(trx, order, modifications); // Update PaymentIntent amount await stripe.paymentIntents.update(order.stripe_payment_intent_id, { amount: newTotal, metadata: { modified_at: new Date().toISOString(), modification_reason: 'customer_request', }, }); // Update order total await trx('orders') .where({ id: orderId }) .update({ total_amount: newTotal, updated_at: new Date(), }); }); }

Post-Payment Modifications (Refund + New Payment)

For paid orders, you need to refund the difference and potentially charge for additions:

interface OrderModification { type: 'add' | 'remove' | 'change_quantity'; item_id?: string; product_id?: string; quantity?: number; } async function modifyPaidOrder( orderId: string, modifications: OrderModification[] ): Promise<{ refund?: OrderRefund; newCharge?: Stripe.PaymentIntent }> { const order = await db('orders').where({ id: orderId }).first(); if (order.status !== 'paid') { throw new Error('Order must be paid to modify'); } // Calculate the delta const { amountDelta, modifiedItems } = await this.calculateModificationDelta( order, modifications ); if (amountDelta < 0) { // Customer owes less - issue refund const refund = await this.processPartialRefund({ order_id: orderId, items: modifiedItems.filter(i => i.quantity < 0), reason: 'order_modification', }); return { refund }; } else if (amountDelta > 0) { // Customer owes more - create new payment const newPaymentIntent = await stripe.paymentIntents.create({ amount: amountDelta, currency: order.currency, customer: order.stripe_customer_id, metadata: { original_order_id: orderId, reason: 'order_modification', }, }); return { newCharge: newPaymentIntent }; } else { // No price change, just update items await this.updateOrderItems(orderId, modifiedItems); return {}; } }

This pattern is cleaner than trying to adjust a single payment—you maintain a clear audit trail of the original payment and any modifications.

Common Pitfalls and Edge Cases

After implementing dozens of refund systems, here are the issues that consistently catch developers off guard:

1. Race Conditions on Concurrent Refunds

Problem: Two customer service reps process refunds simultaneously, over-refunding the order.

Solution: Use database row locking and idempotency keys:

// Always lock the order row const order = await db('orders') .where({ id: orderId }) .forUpdate() // Critical: prevents concurrent modifications .first(); // Use deterministic idempotency keys const idempotencyKey = `refund_${orderId}_${itemId}_${timestamp}`;

2. Stripe Fee Handling

Problem: Stripe charges a fee on the original transaction. When you refund, Stripe refunds their fee proportionally, but this isn't automatic for partial refunds.

Reality check: For a $100 charge with a $3.20 fee, if you refund $50, Stripe refunds $1.60 of their fee. Your net cost is the remaining $1.60 fee plus any refunded amount. This matters for your accounting:

// Track fees separately for accurate accounting interface RefundRecord { amount: number; // Amount refunded to customer stripe_fee_refunded: number; // Fee Stripe refunded to you net_cost: number; // Your actual cost (amount - fee_refunded) } // Calculate from Stripe's Refund object const refund = await stripe.refunds.retrieve(refundId, { expand: ['charge', 'charge.balance_transaction'], }); const feeRefunded = refund.charge.balance_transaction?.fee_details ?.find(f => f.type === 'stripe_fee')?.amount || 0;

3. Inventory Timing Issues

Problem: You refund an item but don't immediately return it to inventory. Another customer orders it, but the item is actually out of stock.

Solution: Implement a two-phase inventory return:

async function handleRefundInventory(refund: OrderRefund): Promise<void> { for (const item of refund.items) { // Phase 1: Mark as "pending return" immediately await db('inventory_transactions').insert({ product_id: item.product_id, quantity: item.quantity, type: 'refund_pending', order_id: refund.order_id, status: 'pending', }); // Phase 2: After physical return confirmed, make available // This happens later via admin action or automated after X days } }

4. Partial Refunds on Discounted Orders

Problem: Customer used a 20% discount code. They want to refund one of three items. What's the refund amount?

Options:

  1. Proportional: Refund 1/3 of total paid (includes discount)
  2. Full item price: Refund full item price (ignores discount)
  3. Pro-rated discount: Complex calculation distributing discount across items

Recommended approach:

function calculateDiscountedRefund( item: OrderItem, order: Order, refundQuantity: number ): number { if (!order.discount_amount) { // No discount, simple calculation return item.unit_price * refundQuantity; } // Calculate item's proportion of pre-discount total const preDiscountTotal = order.items.reduce( (sum, i) => sum + (i.unit_price * i.quantity), 0 ); const itemPreDiscountTotal = item.unit_price * item.quantity; const itemDiscountProportion = itemPreDiscountTotal / preDiscountTotal; // Apply proportional discount to this item const itemDiscount = order.discount_amount * itemDiscountProportion; const itemFinalPrice = itemPreDiscountTotal - itemDiscount; const perUnitPrice = itemFinalPrice / item.quantity; return Math.round(perUnitPrice * refundQuantity); }

5. Refunds After Disputes

Problem: Customer disputes a charge, wins, then requests a refund for part of the order.

Critical: Once a dispute is won by the customer, you cannot refund that charge. Stripe will reject the refund API call. Check dispute status first:

async function canRefund(paymentIntentId: string): Promise<boolean> { const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ['latest_charge', 'latest_charge.dispute'], }); const dispute = paymentIntent.latest_charge?.dispute; if (dispute && ['won', 'charge_refunded'].includes(dispute.status)) { return false; } return true; }

6. Subscription vs One-Time Refunds

Problem: Treating subscription refunds the same as one-time payment refunds.

Key difference: For subscriptions, you typically want to issue a credit or adjust future invoices rather than refunding, unless the customer is canceling:

// For one-time payments await stripe.refunds.create({ payment_intent: 'pi_xxx', amount: 1000, }); // For subscriptions - issue credit instead await stripe.customers.createBalanceTransaction(customerId, { amount: -1000, // Negative = credit currency: 'usd', description: 'Credit for service issue', }); // Or adjust the next invoice await stripe.invoiceItems.create({ customer: customerId, amount: -1000, currency: 'usd', description: 'Adjustment for previous billing issue', });

For more on subscription-specific refund patterns, see our guide on Building a Self-Service Customer Portal with Stripe Billing.

Best Practices Summary

Here's a checklist for production-ready refund implementations:

Data Integrity:

  • ✅ Use database transactions for all refund operations
  • ✅ Implement row locking to prevent concurrent refunds
  • ✅ Track refunded amounts at both order and item levels
  • ✅ Maintain an audit trail of all refund attempts
  • ✅ Sync refund state from Stripe webhooks as source of truth

Stripe Integration:

  • ✅ Always use idempotency keys for refund requests
  • ✅ Handle both PaymentIntent and Charge refund patterns
  • ✅ Validate refund amount doesn't exceed available balance
  • ✅ Check for disputes before attempting refunds
  • ✅ Map internal refund reasons to Stripe's reason enum

Business Logic:

  • ✅ Calculate proportional refunds for discounted orders
  • ✅ Handle partial quantity refunds correctly
  • ✅ Implement two-phase inventory returns
  • ✅ Track Stripe fee refunds for accurate accounting
  • ✅ Support both customer-initiated and admin-initiated refunds

Error Handling:

  • ✅ Gracefully handle Stripe API failures
  • ✅ Implement retry logic with exponential backoff
  • ✅ Log all refund attempts and outcomes
  • ✅ Alert on failed refunds that need manual intervention
  • ✅ Provide clear error messages to customer service reps

Customer Experience:

  • ✅ Send confirmation emails for all refunds
  • ✅ Provide estimated refund timing (5-10 business days)
  • ✅ Show refund status in customer order history
  • ✅ Support self-service refunds where appropriate
  • ✅ Handle external refunds (from Stripe Dashboard) gracefully

Conclusion

Implementing robust partial refund and order modification workflows requires more than just calling Stripe's refund API. You need to maintain consistency across your order management system, handle edge cases like concurrent refunds and discounted orders, and provide a seamless experience for both customers and your support team.

The patterns I've shared here—database transactions with row locking, idempotent refund requests, webhook-based synchronization, and proportional refund calculations—form the foundation of a production-ready refund system. They'll save you from the bugs that only appear when customers start requesting refunds at scale.

Key takeaways: treat refunds as first-class operations in your data model, always use Stripe as your source of truth via webhooks, and build your system to handle the inevitable edge cases from day one. The time you invest in proper refund handling pays dividends in customer satisfaction and operational efficiency.

If you're building an e-commerce platform and need help implementing these patterns, our E-commerce payment integration service includes refund workflow design, Stripe integration, and webhook handling. We can also conduct a Stripe audit of your existing implementation to identify potential issues before they impact customers.

Need Expert Implementation?

I provide professional Stripe integration and Next.js optimization services with fixed pricing and fast delivery.