Picture this: You're building an app, everything's going smooth, and then your client drops the bomb...

Payment Gateway Chaos: How I Converted Multiple Providers into One

#javascript#typescript#systemdesign#stripe
Posted on May 25, 20254 min read

Picture this: You're building an app, everything's going smooth, and then your client drops the bomb — "Can we support PayU, Stripe, and maybe Razorpay too? You know, for flexibility."

Suddenly you're staring at three different APIs, each with their own quirks, data formats, and authentication methods. PayU wants a hash, Stripe wants sessions, and don't even get me started on their different ways of handling amounts (seriously, who decided some should use paise and others should use rupees?).

I recently found myself in this exact situation while working on a project, and instead of copy-pasting code like a barbarian, I decided to build something that wouldn't make me want to throw my laptop out the window every time I needed to add a new gateway.

The Problem: Payment Gateway Spaghetti

Most developers handle multiple payment gateways like this:

  • Create separate functions for each gateway
  • Copy-paste similar logic everywhere
  • End up with a maintenance nightmare
  • Cry a little when requirements change

There's got to be a better way, right? Right?

The Solution: Abstract the Chaos Away

Here's the pattern that saved my sanity (and probably my career):

Step 1: Create a Universal Payment Interface

First, I created a common type that could handle all payment gateways without losing my mind:

    export interface PurchaseTransactionData {
      session: any;
      itemId: string;
      amount: number;
      itemType: PurchaseType;
      productinfo: string;
      gateway: PaymentGateway;
    }

    export interface PaymentData {
      // Common fields for all payment gateways
      gateway: PaymentGateway;
      transactionId: string;
      amount?: string;
      productinfo?: string;
      firstname?: string;
      email?: string;
      phone?: string;
      surl?: string;
      furl?: string;

      // PayU specific fields
      txnid?: string;
      hash?: string;
      key?: string;

      // Stripe specific fields
      stripePayUrl?: string;
      publishableKey?: string;
    }
Enter fullscreen mode Exit fullscreen mode

Is it perfect? No. Does it work? Absolutely. The key insight here is that you need one interface to rule them all — even if some fields are gateway-specific.

Step 2: The Abstract Base Class (The Real MVP)

    import { PaymentData, PurchaseTransactionData } from "@/types/payment";
    import { prisma, PaymentStatus } from "@repo/db";

    export abstract class PaymentGatewayBase {
      abstract createPaymentData(
        transaction: any,
        transactionData: PurchaseTransactionData
      ): Promise<PaymentData>;

      async createTransaction(transactionData: PurchaseTransactionData) {
        return await prisma.paymentTransaction.create({
          data: {
            userId: transactionData.session.user.id,
            status: PaymentStatus.PENDING,
            amount: transactionData.amount,
            gateway: transactionData.gateway,
            itemId: transactionData.itemId,
            itemType: transactionData.itemType,
          },
        });
      }
    }
Enter fullscreen mode Exit fullscreen mode

This abstract class does two things:

  1. Forces every gateway implementation to have a createPaymentData method
  2. Handles the common transaction creation logic so you don't repeat yourself

Step 3: Gateway Implementations (Where the Magic Happens)

Now here's where it gets fun. Each gateway just extends the base class and implements their specific logic:

PayU Implementation:

    export class PayUGateway extends PaymentGatewayBase {
      public async createPaymentData(
        transaction: any,
        transactionData: PurchaseTransactionData
      ): Promise<PaymentData> {
        const { merchantKey, merchantSalt } = await getPayUCredentials();

        const data = {
          key: merchantKey,
          gateway: PaymentGateway.PAYU,
          transactionId: transaction.id,
          txnid: transaction.id,
          amount: (transaction.amount / 100).toString(), // PAYU wants floats
          productinfo: transactionData.productinfo,
          firstname: transactionData.session.user.name ?? "John Doe",
          email: transactionData.session.user.email!,
          phone: "1234567890",
          surl: `${process.env.NEXT_PUBLIC_SITE_URL}/api/payu-response?status=success`,
          furl: `${process.env.NEXT_PUBLIC_SITE_URL}/api/payu-response?status=failure`,
          hash: "",
        };

        data.hash = generateHash(data, merchantSalt, merchantKey);
        return data;
      }
    }
Enter fullscreen mode Exit fullscreen mode

Stripe Implementation:

    export class StripeGateway extends PaymentGatewayBase {
      public async createPaymentData(
        transaction: PaymentTransaction,
        transactionData: PurchaseTransactionData
      ): Promise<PaymentData> {
        const stripeCredentials = await getStripeCredentials();
        const stripe = new Stripe(stripeCredentials.secretKey);

        const session = await stripe.checkout.sessions.create({
          mode: "payment",
          customer_email: transactionData.session.user.email,
          client_reference_id: transaction.id,
          line_items: [
            {
              price_data: {
                currency: "inr",
                product_data: {
                  name: transactionData.productinfo,
                },
                unit_amount: transactionData.amount, // Stripe wants paise
              },
              quantity: 1,
            },
          ],
          billing_address_collection: "required",
          success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/shop/?transactionId=${transaction.id}`,
          cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/api/stripe-response?transactionId=${transaction.id}`,
        });

        return {
          gateway: PaymentGateway.STRIPE,
          transactionId: transaction.id,
          stripePayUrl: session.url || "",
          publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
        };
      }
    }
Enter fullscreen mode Exit fullscreen mode

Step 4: The Factory Pattern (Because We're Not Animals)

But wait, there's more! Manually instantiating gateway classes everywhere is still messy. Enter the Factory pattern:

    import { PaymentGateway } from "@repo/db";
    import "server-only";
    import { PaymentGatewayBase } from "./payment-base";
    import { PayUGateway } from "./payu";
    import { StripeGateway } from "./stripe";

    export class PaymentGatewayFactory {
      static create(gateway: PaymentGateway): PaymentGatewayBase {
        switch (gateway) {
          case PaymentGateway.PAYU:
            return new PayUGateway();
          case PaymentGateway.STRIPE:
            return new StripeGateway();
          default:
            throw new Error(`Unsupported payment gateway: ${gateway}`);
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

Now your server-side code becomes beautifully simple:

    const gateway = PaymentGatewayFactory.create(selectedGateway);
    const transaction = await gateway.createTransaction(transactionData);
    const paymentData = await gateway.createPaymentData(transaction, transactionData);
Enter fullscreen mode Exit fullscreen mode

Step 5: Frontend Magic (The Cherry on Top)

The frontend doesn't need to know about your fancy abstractions. It just needs to handle the response and redirect users accordingly:

    if (paymentResult.success) {
      switch (paymentResult.paymentData?.gateway) {
        case "PAYU":
          redirectToPaymentPage(paymentResult.paymentData);
          break;
        case "STRIPE":
          window.location.href = 
            paymentResult.paymentData?.stripePayUrl || window.location.href;
          break;
        default:
          throw new Error("Unsupported payment gateway");
      }
    }
Enter fullscreen mode Exit fullscreen mode

Each gateway gets its own redirect logic, but the main flow stays clean and predictable.

Factory pattern image

Step 6: Adding New Gateways (The Real Test)

Now adding a new payment gateway is genuinely simple:

  1. Create a new class that extends PaymentGatewayBase
  2. Implement the createPaymentData method
  3. Add the case to your Factory class
  4. Add the frontend redirect logic
  5. That's it. You're done.

Want to add Razorpay? Just create RazorpayGateway extends PaymentGatewayBase, add it to the factory, and handle the frontend redirect. No existing code needs to change.

The Beauty of This Approach

This pattern gives you:

  • Consistency: Every gateway follows the same contract
  • Maintainability: Common logic lives in one place
  • Scalability: Adding new gateways doesn't break existing code
  • Testability: You can mock the abstract class for unit tests
  • Sanity: You won't hate your life when requirements change

Conclusion

Payment gateway integration doesn't have to be a nightmare. With a little bit of abstraction and a lot of TypeScript magic, you can build something that's actually maintainable.

The next time someone asks you to "just add one more payment gateway," you can smile knowingly instead of updating your resume.

Now if you'll excuse me, I need to go figure out why Razorpay's webhook decided to stop working again. Some things never change.

Made with ❤️ by Divyanshu Lohani