跳到正文

Dec 27, 2024

Building Scalable E-Commerce with Stripe: Payment Architecture for Growth

10 min · Stripe · E-Commerce · Payments · Architecture · Integration

Building Scalable E-Commerce with Stripe: Payment Architecture for Growth

Stripe has become the de facto standard for online payments. It handles complexity—PCI compliance, fraud prevention, payment methods, currencies—so you can focus on your business.

However, integrating Stripe into a scalable e-commerce platform requires thoughtful architecture. This guide covers production patterns used by companies processing billions in GMV.

Stripe Architecture Overview

A robust e-commerce payment system has these components:

┌─────────────────────────────────────┐
│   E-Commerce Frontend               │
│   (Checkout Page, Payment Form)     │
└────────────┬────────────────────────┘
             │
             ▼
┌─────────────────────────────────────┐
│   Stripe Elements / Payment Element │
│   (Client-side payment capture)     │
└────────────┬────────────────────────┘
             │
             ▼
┌─────────────────────────────────────┐
│   Your Backend API                  │
│   (Create PaymentIntent, Confirm)   │
└────────┬──────────────────────────┬──┘
         │                          │
         ▼                          ▼
    ┌─────────────┐         ┌──────────────┐
    │ Stripe API  │         │ Webhooks     │
    │ (Sync)      │         │ (Async)      │
    └──────┬──────┘         └──────┬───────┘
           │                       │
           └───────────┬───────────┘
                       ▼
            ┌────────────────────────┐
            │ Database               │
            │ (Payments, Orders)     │
            └────────────────────────┘

Setting Up Stripe

Install Dependencies

npm install stripe
npm install --save-dev @stripe/stripe-js

Environment Setup

# .env.local
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_your_key
STRIPE_SECRET_KEY=sk_live_your_key
STRIPE_WEBHOOK_SECRET=whsec_your_secret

Initialize Stripe Client

import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-10',
  appInfo: {
    name: 'My E-Commerce Store',
    version: '1.0.0',
  },
});

Payment Intent Flow

The Payment Intent is Stripe's recommended approach for handling payments:

interface CreatePaymentIntentRequest {
  orderId: string;
  customerId: string;
  amount: number;
  currency: string;
  email: string;
}

export async function createPaymentIntent(
  request: CreatePaymentIntentRequest
): Promise<{ clientSecret: string; paymentIntentId: string }> {
  try {
    // Verify order exists and amount is correct
    const order = await db.order.findUnique({
      where: { id: request.orderId },
    });

    if (!order) {
      throw new Error('Order not found');
    }

    if (order.totalAmount * 100 !== request.amount) {
      throw new Error('Amount mismatch');
    }

    // Check for existing payment intent
    const existingPayment = await db.payment.findUnique({
      where: { orderId: request.orderId },
    });

    if (
      existingPayment &&
      existingPayment.status === 'succeeded'
    ) {
      throw new Error('Order already paid');
    }

    // Create or update payment intent
    let paymentIntent;

    if (
      existingPayment &&
      existingPayment.stripePaymentIntentId
    ) {
      // Update existing intent
      paymentIntent = await stripe.paymentIntents.update(
        existingPayment.stripePaymentIntentId,
        {
          amount: request.amount,
          description: `Order ${request.orderId}`,
          metadata: {
            orderId: request.orderId,
            customerId: request.customerId,
          },
        }
      );
    } else {
      // Create new intent
      paymentIntent = await stripe.paymentIntents.create({
        amount: request.amount, // Amount in cents
        currency: request.currency,
        description: `Order ${request.orderId}`,
        customer: request.customerId,
        receipt_email: request.email,
        metadata: {
          orderId: request.orderId,
          customerId: request.customerId,
        },
        // Automatic payment methods for modern wallets
        automatic_payment_methods: {
          enabled: true,
        },
        // Save payment method for future use
        setup_future_usage: 'on_session',
      });
    }

    // Store/update payment record
    await db.payment.upsert({
      where: { orderId: request.orderId },
      create: {
        orderId: request.orderId,
        customerId: request.customerId,
        stripePaymentIntentId: paymentIntent.id,
        amount: request.amount,
        currency: request.currency,
        status: 'created',
        metadata: paymentIntent.metadata,
      },
      update: {
        stripePaymentIntentId: paymentIntent.id,
        status: 'created',
      },
    });

    return {
      clientSecret: paymentIntent.client_secret!,
      paymentIntentId: paymentIntent.id,
    };
  } catch (error) {
    console.error('Payment intent creation failed:', error);
    throw error;
  }
}

Frontend: Collecting Payment Details

Use Stripe Elements for secure payment collection:

// app/checkout/page.tsx
'use client';

import { loadStripe } from '@stripe/stripe-js';
import {
  Elements,
  PaymentElement,
  useStripe,
  useElements,
} from '@stripe/react-stripe-js';
import { useState } from 'react';

const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);

export default function CheckoutPage() {
  const [orderId, setOrderId] = useState<string>('');

  return (
    <div className="max-w-2xl mx-auto py-12">
      <h1 className="text-3xl font-bold mb-8">Checkout</h1>

      <Elements
        stripe={stripePromise}
        options={{
          mode: 'payment',
          amount: 5000, // $50.00 in cents
          currency: 'usd',
          appearance: {
            theme: 'dark',
            variables: {
              colorPrimary: '#3b82f6',
            },
          },
        }}
      >
        <CheckoutForm orderId={orderId} />
      </Elements>
    </div>
  );
}

interface CheckoutFormProps {
  orderId: string;
}

function CheckoutForm({ orderId }: CheckoutFormProps) {
  const stripe = useStripe();
  const elements = useElements();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [processing, setProcessing] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setLoading(true);
    setError(null);

    try {
      // Step 1: Create payment intent on backend
      const response = await fetch('/api/checkout/create-payment-intent', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ orderId }),
      });

      if (!response.ok) {
        throw new Error('Failed to create payment intent');
      }

      const { clientSecret } = await response.json();

      // Step 2: Confirm payment with Stripe
      setProcessing(true);
      const result = await stripe.confirmPayment({
        elements,
        clientSecret,
        confirmParams: {
          return_url: `${window.location.origin}/checkout/success`,
        },
      });

      if (result.error) {
        setError(result.error.message || 'Payment failed');
      } else if (result.paymentIntent?.status === 'succeeded') {
        // Payment successful - redirect to success page
        window.location.href = '/checkout/success';
      }
    } catch (err) {
      setError(
        err instanceof Error ? err.message : 'An error occurred'
      );
    } finally {
      setLoading(false);
      setProcessing(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      {/* Payment Element handles all payment methods */}
      <PaymentElement />

      {error && (
        <div className="text-red-600 text-sm">
          {error}
        </div>
      )}

      <button
        disabled={!stripe || processing || loading}
        className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-bold py-3 rounded-lg transition"
      >
        {processing ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

Webhook Handling

Webhooks are critical for handling async payment events. Never rely solely on client-side confirmations:

// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const headersList = await headers();
  const signature = headersList.get('stripe-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'No signature' },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json(
      { error: 'Signature verification failed' },
      { status: 400 }
    );
  }

  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        await handlePaymentSucceeded(
          event.data.object as Stripe.PaymentIntent
        );
        break;

      case 'payment_intent.payment_failed':
        await handlePaymentFailed(
          event.data.object as Stripe.PaymentIntent
        );
        break;

      case 'charge.refunded':
        await handleRefund(event.data.object as Stripe.Charge);
        break;

      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'invoice.payment_succeeded':
        await handleInvoicePaymentSucceeded(
          event.data.object as Stripe.Invoice
        );
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (err) {
    console.error('Webhook processing failed:', err);
    // Return 500 so Stripe retries
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    );
  }
}

async function handlePaymentSucceeded(
  paymentIntent: Stripe.PaymentIntent
): Promise<void> {
  const orderId = paymentIntent.metadata?.orderId;

  if (!orderId) {
    console.error('No orderId in payment intent metadata');
    return;
  }

  // Update payment status
  await db.payment.update({
    where: { orderId },
    data: {
      status: 'succeeded',
      stripePaymentIntentId: paymentIntent.id,
      stripeChargeId:
        typeof paymentIntent.charges.data[0] === 'string'
          ? paymentIntent.charges.data[0]
          : paymentIntent.charges.data[0]?.id,
      confirmedAt: new Date(),
    },
  });

  // Update order status
  await db.order.update({
    where: { id: orderId },
    data: {
      status: 'paid',
      paidAt: new Date(),
    },
  });

  // Send confirmation email
  const order = await db.order.findUnique({
    where: { id: orderId },
    include: { customer: true, items: true },
  });

  if (order) {
    await sendOrderConfirmationEmail(order);
  }

  // Trigger fulfillment
  await startFulfillmentProcess(orderId);
}

async function handlePaymentFailed(
  paymentIntent: Stripe.PaymentIntent
): Promise<void> {
  const orderId = paymentIntent.metadata?.orderId;

  if (!orderId) {
    console.error('No orderId in payment intent metadata');
    return;
  }

  // Update payment status
  await db.payment.update({
    where: { orderId },
    data: {
      status: 'failed',
      failureReason:
        paymentIntent.last_payment_error?.message || 'Unknown error',
    },
  });

  // Update order status
  await db.order.update({
    where: { id: orderId },
    data: {
      status: 'payment_failed',
    },
  });

  // Notify customer
  const order = await db.order.findUnique({
    where: { id: orderId },
    include: { customer: true },
  });

  if (order) {
    await sendPaymentFailedEmail(
      order.customer.email,
      order
    );
  }
}

async function handleRefund(charge: Stripe.Charge): Promise<void> {
  // Find associated order through payment
  const payment = await db.payment.findUnique({
    where: { stripeChargeId: charge.id },
  });

  if (!payment) {
    console.warn(`No payment found for charge ${charge.id}`);
    return;
  }

  // Create refund record
  await db.refund.create({
    data: {
      orderId: payment.orderId,
      amount: charge.amount_refunded,
      stripeRefundId: charge.refunded ? charge.id : null,
      reason: charge.metadata?.reason || 'Customer request',
      processedAt: new Date(),
    },
  });

  // Update order if fully refunded
  if (charge.refunded === charge.amount) {
    await db.order.update({
      where: { id: payment.orderId },
      data: { status: 'refunded' },
    });
  }
}

Subscription Handling

For recurring payments:

interface CreateSubscriptionRequest {
  customerId: string;
  priceId: string;
  paymentMethodId?: string;
  trialDays?: number;
}

export async function createSubscription(
  request: CreateSubscriptionRequest
): Promise<Stripe.Subscription> {
  const subscription = await stripe.subscriptions.create({
    customer: request.customerId,
    items: [{ price: request.priceId }],
    payment_settings: {
      save_default_payment_method: 'on_subscription',
      default_mandate_id: request.paymentMethodId,
    },
    trial_period_days: request.trialDays,
    metadata: {
      createdAt: new Date().toISOString(),
    },
  });

  // Store subscription in database
  await db.subscription.create({
    data: {
      stripeSubscriptionId: subscription.id,
      customerId: request.customerId,
      priceId: request.priceId,
      status: subscription.status,
      currentPeriodStart: new Date(
        subscription.current_period_start * 1000
      ),
      currentPeriodEnd: new Date(
        subscription.current_period_end * 1000
      ),
    },
  });

  return subscription;
}

export async function cancelSubscription(
  subscriptionId: string,
  immediate: boolean = false
): Promise<void> {
  await stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: !immediate,
  });

  await db.subscription.update({
    where: { stripeSubscriptionId: subscriptionId },
    data: {
      status: 'canceled',
      canceledAt: new Date(),
    },
  });
}

PCI Compliance

Stripe handles most PCI compliance, but you must:

  • Never log sensitive data: No card numbers, CVVs, or authentication codes
  • Use Stripe for payment processing: Never implement custom payment card handling
  • Secure your API keys: Use environment variables, rotate regularly
  • Implement HTTPS: All payment data must be encrypted in transit
  • Validate data on backend: Don't trust client-side validation for payments
// Good: Never log sensitive payment data
const payment = await stripe.paymentIntents.retrieve(piId);
console.log('Payment status:', payment.status); // OK
// console.log('Card:', payment.payment_method); // NEVER do this

// Good: Validate amounts on backend before charging
const orderAmount = (await db.order.findUnique({ where: { id } }))
  .totalAmount;
if (orderAmount * 100 !== paymentAmount) {
  throw new Error('Amount mismatch - potential fraud');
}

Fraud Prevention

Leverage Stripe's fraud tools:

// Enable Radar for fraud detection
const paymentIntent = await stripe.paymentIntents.create({
  amount: 5000,
  currency: 'usd',
  radar_options: {
    session: sessionId, // Track customer session
  },
  // Risk assessment rules
  statement_descriptor: 'MY STORE',
});

// Handle declined charges
if (
  paymentIntent.last_payment_error?.decline_code ===
  'declined'
) {
  // Potential fraud or insufficient funds
  // Notify fraud team and customer
}

Scaling Considerations

Rate Limiting: Stripe has rate limits. Implement exponential backoff:

async function stripeWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3
): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (
        error instanceof Stripe.errors.RateLimitError &&
        attempt < maxRetries - 1
      ) {
        const delay = Math.pow(2, attempt) * 1000; // exponential backoff
        await new Promise((resolve) => setTimeout(resolve, delay));
        continue;
      }
      throw error;
    }
  }
  throw new Error('Max retries exceeded');
}

Idempotency: Always use idempotency keys for critical operations:

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 5000,
    currency: 'usd',
  },
  {
    idempotencyKey: `order-${orderId}-${Date.now()}`,
  }
);

Analytics & Monitoring

Track payment metrics:

interface PaymentMetrics {
  totalProcessed: number;
  successRate: number;
  averageAmount: number;
  declinedTransactions: number;
  refundRate: number;
  avgProcessingTime: number;
}

async function getPaymentMetrics(
  period: 'day' | 'week' | 'month'
): Promise<PaymentMetrics> {
  const startDate = getStartDate(period);

  const payments = await db.payment.findMany({
    where: { createdAt: { gte: startDate } },
  });

  const successful = payments.filter(
    (p) => p.status === 'succeeded'
  ).length;
  const failed = payments.filter(
    (p) => p.status === 'failed'
  ).length;
  const refunded = payments.filter(
    (p) => p.status === 'refunded'
  ).length;

  return {
    totalProcessed: successful + failed,
    successRate: (successful / (successful + failed)) * 100,
    averageAmount:
      payments.reduce((sum, p) => sum + p.amount, 0) /
      payments.length,
    declinedTransactions: failed,
    refundRate: (refunded / successful) * 100,
    avgProcessingTime: 0, // Calculate from timestamps
  };
}

Conclusion

Stripe provides the infrastructure for processing payments reliably and securely. By implementing proper architecture patterns—Payment Intents, webhook handling, idempotency—you can build payment systems that scale to millions in transactions while maintaining security and reliability.

The key is treating payments as a critical system: monitor it obsessively, test thoroughly, and always have fallback strategies for failures.

MK