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:
| Component | Xendit | NewPaymentService | Location |
|---|---|---|---|
| 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.