When we initiated our project, our vision was clear: create an application tailored for the Indian audience. Naturally, we chose RazorPay as our payment gateway, given its popularity in India. Based on our initial target audience, this decision set the stage for an exciting journey of scaling and adaptation in our payment integration process.
As our project's scope expanded beyond Indian borders, we faced a critical question: How could we transform our tightly coupled RazorPay integration into a scalable and reliable system accommodating various payment gateways?
Initial Implementation
Our first implementation was tightly coupled with RazorPay. We created specific database tables for RazorPay payments and webhooks. Here's what our initial schema looked like:
create_table "razorpay_payments", charset: "latin1", force: :cascade do |t|
t.bigint "booking_id", null: false
t.string "razorpay_order_id"
t.string "razorpay_payment_id", null: false
t.string "razorpay_signature_id"
t.string "razorpay_refund_id"
t.string "razorpay_transfer_id"
t.decimal "amount", precision: 10, scale: 3
t.decimal "transfered_amount", precision: 10, scale: 3
t.string "status"
t.json "payload"
t.text "error_description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "razorpay_webhooks", charset: "latin1", force: :cascade do |t|
t.string "razorpay_payment_id"
t.text "webhook_data"
t.integer "event"
t.string "razorpay_order_id"
t.bigint "payment_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
As we expanded to include Stripe and PayPal, we simply replicated this approach, creating separate tables for each gateway and duplicating much of the code with minor modifications.
This method quickly showed its flaws:
The integration of GMO, a Japanese payment gateway, was our turning point. We realized our code lacked scalability and reliability. It was time to step back, rethink our architecture, and embrace scalable coding practices.
We went back to the drawing board and redesigned our payment system using the following design patterns:
1. Generic Payment Table: We introduced a single, generic table for all payment gateways, current and future.
create_table "payments", charset: "utf8mb3", force: :cascade do |t|
t.float "amount", null: false
t.string "reference"
t.datetime "paid_at"
t.integer "gateway", default: 0, null: false
t.string "payment_type"
t.integer "status"
t.string "entity_type"
t.bigint "entity_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "invoice_number"
t.json "gst"
t.json "breakup"
t.index ["entity_type", "entity_id"], name: "index_payments_on_entity"
end
2. Unified Webhook Table: A common table to store callbacks from all gateways.
create_table "payments_webhooks", charset: "utf8mb3", force: :cascade do |t|
t.string "event", null: false
t.json "payload", null: false
t.integer "gateway", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
3. Factory Class: This class returns an instance of the appropriate gateway implementation based on input.
# factory.rb
class Payments::Factory
def self.interface(gateway)
case gateway
when 'razorpay'
return Payments::Razorpay::Implementation.new
when 'stripe'
return Payments::Stripe::Implementation.new
end
end
end
Class: An abstract class defining methods each gateway implementation must include.
# interface.rb
module Payments::Interface
def process_webhook(event, payload)
raise NotImplementedError
end
def create_order(options)
raise NotImplementedError
end
def payment_events
raise NotImplementedError
end
def parse_event(payload)
raise NotImplementedError
end
def verify_webhook(params)
raise NotImplementedError
end
def crud_entity(params)
raise NotImplementedError
end
end
5. Gateway-Specific Implementations: Separate classes for each gateway, implementing the methods defined in the interface.
# razorpay/implementation.rb
require 'razorpay'
class Payments::Razorpay::Implementation
include Payments::Interface
attr_reader :operation, :entity_id, :entity, :event, :payload
PAYMENT_STATUSES = { captured: 'success', failed: 'failed' }
PAYMENT_EVENTS = %w[payment.captured subscription.charged]
def process_webhook(event, payload)
@event, @payload = event, payload
process_payment if PAYMENT_EVENTS.include? event
end
def process_payment
entity = payload.dig(:payload, :payment, :entity)
payment_object = {
reference: entity.dig(:id), amount: (entity.dig(:amount) / 100),
status: PAYMENT_STATUSES[entity.dig(:status).to_sym],
paid_at: Time.at(entity.dig(:created_at)).to_datetime,
entity_type: entity.dig(:notes, :entity_type),
entity_id: entity.dig(:notes, :entity_id)
}
'Payment'.constantize.create payment_object
end
def create_order(options)
puts 'Razorpay create_order'
payload = { amount: options[:amount], currency: options[:currency].upcase,
receipt: "booking_rcpt_#{Time.now.to_i}", payment_capture: "1", notes: options[:meta]}
Razorpay::Order.create(payload).id
end
def parse_event(payload)
payload.deep_symbolize_keys!.dig(:event)
end
def verify_webhook(params)
Razorpay::Utility.verify_webhook_signature(params[:webhook_body], params[:signature], params[:secret])
end
end
With this new architecture in place, processing payments and webhooks became a unified process, regardless of the gateway used. Here's how it works:
Payments::Factory.interface('razorpay').create_order(params)
With our new architecture, adding a new gateway became as simple as creating a new implementation file and updating the factory class. For example, to add Stripe:
# stripe/implementation.rb
require 'stripe'
class Payments::Stripe::Implementation
include Payments::Interface
attr_reader :operation, :entity_id, :entity, :event, :payload
def process_webhook(event, payload)
# Stripe-specific implementation
end
def process_payment
# Stripe-specific implementation
end
def create_order(options)
# Stripe-specific implementation
end
def parse_event(payload)
# Stripe-specific implementation
end
def verify_webhook(params)
# Stripe-specific implementation
end
end
Then, update the factory:
# factory.rb
class Payments::Factory
def self.interface(gateway)
case gateway
when 'razorpay'
return Payments::Razorpay::Implementation.new
when 'stripe'
return Payments::Stripe::Implementation.new
end
end
end
1. Scalability: Adding new gateways became a plug-and-play process.
2. Maintainability: Centralized logic reduced code duplication.
3. Flexibility: Switching between gateways requires minimal code changes.
Consistency: A unified approach to handling payments across all gateways.
Our journey from a single, tightly coupled payment gateway to a flexible, multi-gateway system taught us valuable lessons in scalable architecture. By embracing design patterns and thinking ahead, we transformed a potential disaster into a robust, future-proof payment system.
This experience reinforced the importance of scalable coding practices, especially when dealing with critical components like payment processing. As our project continues to grow globally, we're now confident in our ability to adapt to new payment gateways and market requirements.
Remember, when building systems that may need to scale, always consider future expansion possibilities. A little extra effort, in the beginning, can save countless hours of refactoring and reduce technical debt in the long run.
They redesigned their architecture using the Factory and Strategy patterns, creating a generic payment table and unified webhook table to accommodate multiple gateways.
The initial approach led to code duplication, maintenance issues, and difficulty in querying payments across entities. Adding new gateways required creating new tables and duplicating code.
The new approach offers improved scalability, maintainability, flexibility, and consistency. It allows easy addition of new gateways and provides a unified method for handling payments across all gateways.
We offer top-notch product development services, turning ideas into market-ready solutions. Our team builds custom, scalable apps using the latest tech and best practices. Let's create something amazing that'll take your business to new heights.
With 11+ years experience, shaping cutting-edge apps. I thrive on transforming concepts into functional and efficient systems. Catch me playing cricket and belting out karaoke in my leisure time
How To Choose The Best Rendering Strategy in Next.js
Over the years, the way we build and deliver websites has changed a lot. We started with simple static pages that were easy to load but not very interactive. Then came dynamic rendering, where servers built pages on the fly, ...
A Guide To User Behavior Testing using RTL (React Testing Library)
React Testing Library (RTL) derives from best practices in software testing and builds on top of them. While Writing a good test case is crucial for any testing framework, RTL emphasizes a particular approach: it tests behavi...
Redirection Loops: A Beginner’s Guide
Ever clicked a link only to find your browser stuck in an endless loop? Welcome to the world of redirection loops. These pesky web gremlins can frustrate users and harm your site's performance. In this beginner's guide, we'll...