Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

How to Add Payment Method on HH-Server

📋 Step-by-Step Implementation Guide

Step 1: Add Configuration Settings

1.1 Add Environment Variables

Add to .env.env.example, and .env.ci:


# .env (local - not committed)
NEW_PAYMENT_SERVICE_API_KEY=your_secret_key_here
NEW_PAYMENT_SERVICE_PUBLIC_KEY=your_public_key_here
NEW_PAYMENT_SERVICE_WEBHOOK_SECRET=webhook_secret_here
NEW_PAYMENT_SERVICE_ENABLED=true
NEW_PAYMENT_SERVICE_SANDBOX=true

# .env.example (committed template)
NEW_PAYMENT_SERVICE_API_KEY=example_api_key
NEW_PAYMENT_SERVICE_PUBLIC_KEY=example_public_key
NEW_PAYMENT_SERVICE_WEBHOOK_SECRET=example_webhook_secret
NEW_PAYMENT_SERVICE_ENABLED=false
NEW_PAYMENT_SERVICE_SANDBOX=true

# .env.ci (for CI/CD)
NEW_PAYMENT_SERVICE_API_KEY=test_api_key
NEW_PAYMENT_SERVICE_PUBLIC_KEY=test_public_key
NEW_PAYMENT_SERVICE_WEBHOOK_SECRET=test_webhook_secret
NEW_PAYMENT_SERVICE_ENABLED=true
NEW_PAYMENT_SERVICE_SANDBOX=true


1.3 Add Configuration Initializer

Create config/initializers/new_payment_service.rb:

# frozen_string_literal: true

# Configuration for NewPaymentService integration
module NewPaymentServiceConfig
  # API Credentials
  API_KEY = ENV['NEW_PAYMENT_SERVICE_API_KEY']
  PUBLIC_KEY = ENV['NEW_PAYMENT_SERVICE_PUBLIC_KEY']
  WEBHOOK_SECRET = ENV['NEW_PAYMENT_SERVICE_WEBHOOK_SECRET']
  
  # Environment Settings
  ENABLED = ENV.fetch('NEW_PAYMENT_SERVICE_ENABLED', 'false') == 'true'
  SANDBOX = ENV.fetch('NEW_PAYMENT_SERVICE_SANDBOX', 'true') == 'true'
  
  # API Configuration
  BASE_URL = SANDBOX ? 'https://sandbox.api.newpayment.com' : 'https://api.newpayment.com'
  TIMEOUT = ENV.fetch('NEW_PAYMENT_SERVICE_TIMEOUT', '30').to_i
  
  # Payment Settings
  SUPPORTED_CURRENCIES = %w[THB USD EUR SGD].freeze
  MAX_RETRY_ATTEMPTS = 3
  
  # Validation
  def self.configured?
    API_KEY.present? && PUBLIC_KEY.present?
  end
  
  def self.validate!
    raise 'NewPaymentService API_KEY not configured' if API_KEY.blank?
    raise 'NewPaymentService PUBLIC_KEY not configured' if PUBLIC_KEY.blank?
  end
end

Step 2: Database Setup

2.1 Add Payment Method to Enums

Update relevant models with the new payment method:

# app/models/reservation.rb
class Reservation < ApplicationRecord
  # Add to existing payment method enum
  enum payment_method: {
    # ... existing methods
    new_payment_service: 10  # Choose next available number
  }
end

# app/models/payment.rb
class Payment < ApplicationRecord
  enum gateway: {
    # ... existing gateways
    new_payment_service: 10
  }
end

2.2 Create Migration for Payment Data

# db/migrate/YYYYMMDDHHMMSS_add_new_payment_service_support.rb
class AddNewPaymentServiceSupport < ActiveRecord::Migration[5.1]
  def change
    # Add columns to payments table
    add_column :payments, :new_payment_service_payment_id, :string
    add_column :payments, :new_payment_service_charge_id, :string
    add_column :payments, :new_payment_service_metadata, :json
    
    # Add indexes for performance
    add_index :payments, :new_payment_service_payment_id
    add_index :payments, :new_payment_service_charge_id
    
    # Add columns to reservations table (if needed)
    add_column :reservations, :new_payment_service_payment_intent_id, :string
    add_index :reservations, :new_payment_service_payment_intent_id
  end
end

2.3 Create Card Storage Migration (if card storage needed)

# db/migrate/YYYYMMDDHHMMSS_add_new_payment_service_to_cards.rb
class AddNewPaymentServiceToCards < ActiveRecord::Migration[5.1]
  def change
    # Reuse existing gb_primepay_cards table or create new one
    add_column :gb_primepay_cards, :new_payment_service_token, :string
    add_column :gb_primepay_cards, :new_payment_service_customer_id, :string
    add_column :gb_primepay_cards, :payment_gateway, :string, default: 'gb_primepay'
    
    add_index :gb_primepay_cards, :new_payment_service_token
    add_index :gb_primepay_cards, :new_payment_service_customer_id
  end
end

Run migrations:

bundle exec rake db:migrate
bundle exec rake db:migrate RAILS_ENV=test

Step 3: Create Service Layer

3.1 Create Base API Client

Create app/services/new_payment_service/api_client.rb:

# frozen_string_literal: true

module NewPaymentService
  # Base API client for NewPaymentService integration
  class ApiClient
    include ElasticAPM::SpanHelpers
    
    BASE_URL = NewPaymentServiceConfig::BASE_URL
    
    def initialize
      NewPaymentServiceConfig.validate!
    end
    
    # HTTP client with proper timeout and retry configuration
    def connection
      @connection ||= Faraday.new(url: BASE_URL) do |config|
        config.request :timeout, open: 5, read: NewPaymentServiceConfig::TIMEOUT
        config.request :json
        config.response :json, content_type: /\bjson$/
        config.adapter :excon
        config.headers['Authorization'] = "Bearer #{NewPaymentServiceConfig::API_KEY}"
        config.headers['Content-Type'] = 'application/json'
      end
    end
    
    # Make API request with retry logic
    def request(method, path, params = {})
      Retriable.retriable(
        tries: NewPaymentServiceConfig::MAX_RETRY_ATTEMPTS,
        base_interval: 1,
        multiplier: 2,
        on: [Faraday::TimeoutError, Faraday::ConnectionFailed]
      ) do
        response = connection.public_send(method, path, params)
        handle_response(response)
      end
    rescue StandardError => e
      handle_error(e, method, path, params)
    end
    span_method :request
    
    private
    
    def handle_response(response)
      case response.status
      when 200..299
        { success: true, data: response.body }
      when 400..499
        { success: false, error: response.body, status: response.status }
      when 500..599
        { success: false, error: 'Service unavailable', status: response.status }
      else
        { success: false, error: 'Unknown error', status: response.status }
      end
    end
    
    def handle_error(error, method, path, params)
      BUSINESS_LOGGER.error('NewPaymentService API request failed', {
        error: error.message,
        method: method,
        path: path,
        params: params,
        backtrace: error.backtrace&.first(3)
      })
      APMErrorHandler.report(error, { method: method, path: path })
      { success: false, error: error.message }
    end
  end
end

3.2 Create Payment Service

Create app/services/new_payment_service/payment_service.rb:

# frozen_string_literal: true

module NewPaymentService
  # Service for payment operations (charge, refund, fetch)
  class PaymentService
    include ElasticAPM::SpanHelpers
    
    attr_reader :api_client
    
    def initialize
      @api_client = ApiClient.new
    end
    
    # Create payment charge
    # @param amount [Integer] Amount in smallest currency unit (cents)
    # @param currency [String] Currency code (THB, USD, etc.)
    # @param payment_method [String] Payment method ID or token
    # @param metadata [Hash] Additional payment metadata
    # @return [Hash] Payment response
    def create_charge(amount:, currency:, payment_method:, metadata: {})
      BUSINESS_LOGGER.set_business_context(metadata)
      BUSINESS_LOGGER.info('Creating NewPaymentService charge', {
        amount: amount,
        currency: currency,
        reservation_id: metadata[:reservation_id]
      })
      
      params = {
        amount: amount,
        currency: currency.upcase,
        payment_method: payment_method,
        capture: true, # Auto-capture or manual capture
        metadata: metadata
      }
      
      response = api_client.request(:post, '/v1/charges', params)
      
      if response[:success]
        BUSINESS_LOGGER.info('NewPaymentService charge created successfully', {
          charge_id: response.dig(:data, :id),
          amount: amount,
          currency: currency
        })
      else
        BUSINESS_LOGGER.error('NewPaymentService charge creation failed', {
          error: response[:error],
          amount: amount,
          currency: currency
        })
      end
      
      response
    end
    span_method :create_charge
    
    # Fetch payment details
    # @param payment_id [String] Payment ID
    # @return [Hash] Payment details
    def fetch_payment(payment_id)
      BUSINESS_LOGGER.info('Fetching NewPaymentService payment', {
        payment_id: payment_id
      })
      
      response = api_client.request(:get, "/v1/payments/#{payment_id}")
      
      if response[:success]
        BUSINESS_LOGGER.info('Successfully fetched payment details', {
          payment_id: payment_id,
          status: response.dig(:data, :status)
        })
      else
        BUSINESS_LOGGER.error('Failed to fetch payment details', {
          payment_id: payment_id,
          error: response[:error]
        })
      end
      
      response
    end
    span_method :fetch_payment
    
    # Refund payment
    # @param charge_id [String] Charge ID to refund
    # @param amount [Integer, nil] Partial refund amount (nil for full refund)
    # @param reason [String] Refund reason
    # @return [Hash] Refund response
    def refund(charge_id:, amount: nil, reason: 'requested_by_customer')
      BUSINESS_LOGGER.info('Creating NewPaymentService refund', {
        charge_id: charge_id,
        amount: amount,
        reason: reason
      })
      
      params = {
        charge: charge_id,
        reason: reason
      }
      params[:amount] = amount if amount.present?
      
      response = api_client.request(:post, '/v1/refunds', params)
      
      if response[:success]
        BUSINESS_LOGGER.info('Refund created successfully', {
          refund_id: response.dig(:data, :id),
          charge_id: charge_id,
          amount: amount
        })
      else
        BUSINESS_LOGGER.error('Refund creation failed', {
          charge_id: charge_id,
          error: response[:error]
        })
      end
      
      response
    end
    span_method :refund
  end
end

3.3 Create Card Saver Service (like Xendit::CardSaverService)

Create app/services/new_payment_service/card_saver_service.rb:

# frozen_string_literal: true

module NewPaymentService
  # Service to save card information to gb_primepay_cards table
  class CardSaverService
    include ElasticAPM::SpanHelpers
    
    attr_reader :payment_id, :reservation
    
    def initialize(payment_id:, reservation: nil, reservation_id: nil)
      @payment_id = payment_id
      @reservation = reservation || Reservation.find_by(id: reservation_id)
    end
    
    # Save card information from payment
    # @return [Externals::GbPrimepay::Card, nil]
    def save
      return nil if payment_id.blank? || reservation.blank?
      
      # Fetch payment data
      payment_data = fetch_payment_data
      return nil if payment_data.blank?
      
      # Extract card data
      card_data = extract_card_data(payment_data)
      return nil if card_data.blank?
      
      # Save card record
      save_card_record(card_data, reservation.user_id, reservation.id)
    rescue StandardError => e
      BUSINESS_LOGGER.error('Exception while saving card information', {
        error: e.message,
        payment_id: payment_id,
        reservation_id: reservation&.id,
        backtrace: e.backtrace&.first(5)
      })
      APMErrorHandler.report(e, { payment_id: payment_id })
      nil
    end
    span_method :save
    
    private
    
    def fetch_payment_data
      service = PaymentService.new
      response = service.fetch_payment(payment_id)
      response[:success] ? response[:data] : nil
    end
    
    def extract_card_data(payment_data)
      payment_method = payment_data[:payment_method]
      return nil unless payment_method[:type] == 'card'
      
      card = payment_method[:card]
      return nil if card.blank?
      
      {
        last4: card[:last4],
        brand: card[:brand],
        exp_month: card[:exp_month],
        exp_year: card[:exp_year],
        cardholder_name: payment_data[:billing_details][:name],
        token: payment_method[:id]
      }
    end
    
    def save_card_record(card_data, user_id, reservation_id)
      Externals::GbPrimepay::Card.find_or_create_by(
        last_digits: card_data[:last4],
        user_id: user_id,
        expiration_year: card_data[:exp_year].to_s[-2..],
        expiration_month: card_data[:exp_month],
        card_type: card_data[:brand],
        reservation_id: reservation_id
      ) do |card|
        card.new_payment_service_token = card_data[:token]
        card.name = card_data[:cardholder_name]
        card.payment_gateway = 'new_payment_service'
      end
    end
  end
end

Step 4: Create Webhook Handler

4.1 Add Webhook Route

Update config/routes.rb:

Rails.application.routes.draw do
  # ... existing routes
  
  namespace :webhooks do
    # Add new payment service webhook
    post 'new_payment_service', to: 'new_payment_service#create'
  end
end

4.2 Create Webhook Controller

Create app/controllers/webhooks/new_payment_service_controller.rb:

# frozen_string_literal: true

module Webhooks
  # Webhook controller for NewPaymentService payment events
  class NewPaymentServiceController < ApplicationController
    skip_before_action :verify_authenticity_token
    before_action :verify_webhook_signature
    
    def create
      event_type = params[:type]
      event_data = params[:data]
      
      BUSINESS_LOGGER.info('Received NewPaymentService webhook', {
        event_type: event_type,
        payment_id: event_data[:id]
      })
      
      # Enqueue background job to process webhook
      NewPaymentService::WebhookProcessorWorker.perform_async(
        event_type,
        event_data.to_json
      )
      
      render json: { received: true }, status: :ok
    rescue StandardError => e
      BUSINESS_LOGGER.error('Failed to process NewPaymentService webhook', {
        error: e.message,
        params: params,
        backtrace: e.backtrace&.first(5)
      })
      APMErrorHandler.report(e, { webhook_params: params })
      render json: { error: 'Internal error' }, status: :internal_server_error
    end
    
    private
    
    def verify_webhook_signature
      signature = request.headers['X-NewPaymentService-Signature']
      payload = request.body.read
      
      expected_signature = OpenSSL::HMAC.hexdigest(
        'SHA256',
        NewPaymentServiceConfig::WEBHOOK_SECRET,
        payload
      )
      
      unless ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
        BUSINESS_LOGGER.warn('Invalid webhook signature', {
          received_signature: signature
        })
        render json: { error: 'Invalid signature' }, status: :unauthorized
      end
    end
  end
end

Step 5: Create Background Workers

5.1 Webhook Processor Worker

Create app/workers/new_payment_service/webhook_processor_worker.rb:

# frozen_string_literal: true

module NewPaymentService
  # Background worker to process NewPaymentService webhooks
  class WebhookProcessorWorker < ApplicationJob
    include ElasticAPM::SpanHelpers
    
    queue_as :critical  # Use critical queue for payment webhooks
    
    sidekiq_options unique: :until_executed,
                    unique_args: ->(args) { [args[0], args[1]] }
    
    def perform(event_type, event_data_json)
      event_data = JSON.parse(event_data_json, symbolize_names: true)
      
      BUSINESS_LOGGER.set_business_context({
        payment_id: event_data[:id],
        event_type: event_type
      })
      
      case event_type
      when 'charge.succeeded'
        handle_charge_succeeded(event_data)
      when 'charge.failed'
        handle_charge_failed(event_data)
      when 'charge.refunded'
        handle_charge_refunded(event_data)
      else
        BUSINESS_LOGGER.info('Unhandled webhook event type', {
          event_type: event_type
        })
      end
    end
    span_method :perform
    
    private
    
    def handle_charge_succeeded(event_data)
      payment_id = event_data[:id]
      reservation_id = event_data.dig(:metadata, :reservation_id)
      
      BUSINESS_LOGGER.info('Processing charge.succeeded event', {
        payment_id: payment_id,
        reservation_id: reservation_id
      })
      
      # Update payment status
      payment = Payment.find_by(new_payment_service_payment_id: payment_id)
      if payment
        payment.update!(
          status: 'completed',
          completed_at: Time.current
        )
      end
      
      # Update reservation
      reservation = Reservation.find_by(id: reservation_id)
      if reservation
        reservation.update!(
          payment_status: 'paid',
          status: 'confirmed'
        )
        
        # Save card information
        CardSaverService.new(
          payment_id: payment_id,
          reservation: reservation
        ).save
      end
    end
    
    def handle_charge_failed(event_data)
      payment_id = event_data[:id]
      error_message = event_data.dig(:last_payment_error, :message)
      
      BUSINESS_LOGGER.error('Payment charge failed', {
        payment_id: payment_id,
        error: error_message
      })
      
      # Update payment status
      payment = Payment.find_by(new_payment_service_payment_id: payment_id)
      payment&.update!(status: 'failed', error_message: error_message)
    end
    
    def handle_charge_refunded(event_data)
      charge_id = event_data[:id]
      refund_amount = event_data.dig(:amount_refunded)
      
      BUSINESS_LOGGER.info('Processing refund', {
        charge_id: charge_id,
        refund_amount: refund_amount
      })
      
      # Update payment and reservation status
      payment = Payment.find_by(new_payment_service_charge_id: charge_id)
      if payment
        payment.update!(status: 'refunded')
        payment.reservation&.update!(payment_status: 'refunded')
      end
    end
  end
end

Step 6: Integration with Existing Payment Flow

6.1 Update Payment Gateway Selector

Update app/services/payment_gateway_selector.rb (or create if doesn’t exist):

# frozen_string_literal: true

# Service to select appropriate payment gateway based on criteria
class PaymentGatewaySelector
  include ElasticAPM::SpanHelpers
  
  def self.select_gateway(reservation:, payment_method:)
    new(reservation: reservation, payment_method: payment_method).select
  end
  
  def initialize(reservation:, payment_method:)
    @reservation = reservation
    @payment_method = payment_method
  end
  
  def select
    # Selection logic based on business rules
    case @payment_method
    when 'credit_card'
      select_card_gateway
    when 'promptpay'
      :omise
    when 'truemoney'
      :omise
    else
      :gb_primepay # Default gateway
    end
  end
  span_method :select
  
  private
  
  def select_card_gateway
    # Business logic to select gateway
    # Example: Use NewPaymentService for international cards
    if @reservation.currency != 'THB' && NewPaymentServiceConfig::ENABLED
      :new_payment_service
    elsif xendit_available?
      :xendit
    else
      :gb_primepay
    end
  end
  
  def xendit_available?
    # Check if Xendit is configured and available
    ENV['XENDIT_ENABLED'] == 'true'
  end
end

6.2 Update Payment Processing Service

Update existing payment processing service to include new gateway:

# app/services/payment_processor.rb
class PaymentProcessor
  include ElasticAPM::SpanHelpers
  
  def process_payment(reservation, payment_params)
    gateway = PaymentGatewaySelector.select_gateway(
      reservation: reservation,
      payment_method: payment_params[:method]
    )
    
    case gateway
    when :new_payment_service
      process_with_new_payment_service(reservation, payment_params)
    when :xendit
      process_with_xendit(reservation, payment_params)
    when :gb_primepay
      process_with_gb_primepay(reservation, payment_params)
    else
      raise "Unknown gateway: #{gateway}"
    end
  end
  span_method :process_payment
  
  private
  
  def process_with_new_payment_service(reservation, payment_params)
    service = NewPaymentService::PaymentService.new
    
    result = service.create_charge(
      amount: reservation.total_price.cents,
      currency: reservation.currency,
      payment_method: payment_params[:token],
      metadata: {
        reservation_id: reservation.id,
        user_id: reservation.user_id,
        restaurant_id: reservation.restaurant_id
      }
    )
    
    if result[:success]
      # Save payment record
      Payment.create!(
        reservation: reservation,
        gateway: :new_payment_service,
        new_payment_service_payment_id: result.dig(:data, :id),
        new_payment_service_charge_id: result.dig(:data, :charge_id),
        amount: reservation.total_price,
        currency: reservation.currency,
        status: 'processing'
      )
    else
      raise "Payment failed: #{result[:error]}"
    end
  end
end

## 🔄 Comparison with Xendit Implementation

Here’s how the new service maps to Xendit’s structure:

ComponentXenditNewPaymentServiceLocation
Configuration`xendit.rb` initializer`new_payment_service.rb` initializer`config/initializers/`
API Client`Xendit::ApiClient``NewPaymentService::ApiClient``app/services/*/api_client.rb`
Payment Service`Xendit::PaymentService``NewPaymentService::PaymentService``app/services/*/payment_service.rb`
Card Saver`Xendit::CardSaverService``NewPaymentService::CardSaverService``app/services/*/card_saver_service.rb`
Webhook Controller`Webhooks::XenditController``Webhooks::NewPaymentServiceController``app/controllers/webhooks/*_controller.rb`
Webhook Worker`Xendit::ChargeUpdaterService``NewPaymentService::WebhookProcessorWorker``app/workers/*/webhook_processor_worker.rb`
Payment Columns`xendit_payment_request_id``new_payment_service_payment_id`Database columns

-–

## ✅ Checklist for Adding New Payment Service

- [ ] Add environment variables (`.env`, `.env.example`, `.env.ci`) - [ ] Update secrets baseline if needed - [ ] Create configuration initializer - [ ] Add payment method enum to models - [ ] Create database migration for payment data - [ ] Run migrations - [ ] Create API client service - [ ] Create payment service (charge, refund, fetch) - [ ] Create card saver service (if needed) - [ ] Add webhook route - [ ] Create webhook controller - [ ] Create webhook processor worker - [ ] Update payment gateway selector - [ ] Update payment processing service - [ ] Write RSpec tests - [ ] Create VCR cassettes - [ ] Document in `docs/payment_services/` - [ ] Test in sandbox environment - [ ] Configure webhook in provider dashboard - [ ] Deploy to staging - [ ] Test end-to-end flow - [ ] Monitor APM and logs - [ ] Deploy to production

-–

## 🚀 Next Steps

1. **Choose Payment Provider** - Select Stripe, Adyen, 2C2P, etc. 2. **Get API Credentials** - Sign up and get sandbox credentials 3. **Follow Checklist** - Use the steps above 4. **Test Thoroughly** - Use RSpec and manual testing 5. **Monitor in Production** - Watch APM and logs

-–

This guide provides a complete blueprint for adding any new payment service by following the same patterns used in the Xendit integration. Adjust the specifics based on your chosen payment provider’s API.