Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions bin/console
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"
require "paystack_sdk"
require 'bundler/setup'
require 'paystack_sdk'

# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.

require "irb"
require 'irb'
IRB.start(__FILE__)
15 changes: 15 additions & 0 deletions lib/paystack_sdk/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "resources/transfers"
require_relative "resources/banks"
require_relative "resources/verification"
require_relative "resources/charges"
require_relative "utils/connection_utils"

module PaystackSdk
Expand Down Expand Up @@ -121,5 +122,19 @@ def banks
def verification
@verification ||= Resources::Verification.new(@connection)
end

# Provides access to the `Charges` resource.
#
# @return [PaystackSdk::Resources::Charges] An instance of the
# `Charges` resource.
#
# @example
# ```ruby
# charges = client.charges
# response = charges.mobile_money(payload)
# ```
def charges
@charges ||= Resources::Charges.new(@connection)
end
end
end
105 changes: 105 additions & 0 deletions lib/paystack_sdk/resources/charges.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# frozen_string_literal: true

require_relative "base"

module PaystackSdk
module Resources
# The `Charges` resource exposes helpers for initiating and managing charges
# through alternative payment channels such as Mobile Money.
#
# At the moment the SDK focuses on supporting the Mobile Money channel which
# requires posting to the `/charge` endpoint with the customer's email,
# amount, currency, and the provider specific `mobile_money` payload.
class Charges < PaystackSdk::Resources::Base
MOBILE_MONEY_PROVIDERS = %w[mtn atl vod mpesa orange wave].freeze

# Initiates a Mobile Money payment.
#
# @param payload [Hash] The payload containing charge details.
# @option payload [String] :email Customer's email address (required)
# @option payload [Integer] :amount Amount in the lowest currency unit (required)
# @option payload [String] :currency ISO currency code (default: GHS)
# @option payload [String] :reference Optional reference supplied by the merchant
# @option payload [String] :callback_url Optional callback URL for Paystack to redirect to
# @option payload [Hash] :metadata Optional metadata to attach to the transaction
# @option payload [Hash] :mobile_money The mobile money details (required)
# - :phone [String] Customer's mobile money phone number (required)
# - :provider [String] Mobile money provider code (required)
#
# @return [PaystackSdk::Response] The wrapped API response.
# @raise [PaystackSdk::ValidationError] If the payload is invalid.
def mobile_money(payload)
validate_mobile_money_payload!(payload)

normalized_payload = normalize_mobile_money_provider(payload)
response = @connection.post("/charge", normalized_payload)
handle_response(response)
end

# Submits an OTP for authorising a pending Mobile Money charge (e.g. Vodafone).
#
# @param payload [Hash] Payload containing the OTP and charge reference.
# @option payload [String] :otp The OTP supplied by the customer (required)
# @option payload [String] :reference The charge reference returned from initiation (required)
#
# @return [PaystackSdk::Response] The wrapped API response.
# @raise [PaystackSdk::ValidationError] If the payload is invalid.
def submit_otp(payload)
validate_fields!(
payload: payload,
validations: {
otp: {type: :string, required: true},
reference: {type: :reference, required: true}
}
)

response = @connection.post("/charge/submit_otp", payload)
handle_response(response)
end

private

def validate_mobile_money_payload!(payload)
validate_fields!(
payload: payload,
validations: {
email: {type: :email, required: true},
amount: {type: :positive_integer, required: true},
currency: {type: :currency, required: false},
reference: {type: :reference, required: false},
callback_url: {required: false},
metadata: {required: false},
mobile_money: {required: true}
}
)

mobile_money = payload[:mobile_money] || payload["mobile_money"]
validate_hash!(input: mobile_money, name: "mobile_money")

phone = mobile_money[:phone] || mobile_money["phone"]
validate_presence!(value: phone, name: "mobile_money phone")

provider = mobile_money[:provider] || mobile_money["provider"]
validate_mobile_money_provider!(provider)
end

def validate_mobile_money_provider!(provider)
normalized = provider&.to_s&.downcase
validate_allowed_values!(
value: normalized,
allowed_values: MOBILE_MONEY_PROVIDERS,
name: "mobile_money provider",
allow_nil: false
)
end

def normalize_mobile_money_provider(payload)
mm = payload[:mobile_money] || payload["mobile_money"] || {}
provider = mm[:provider] || mm["provider"]
normalized_provider = provider&.to_s&.downcase
# Avoid mutating the caller's payload
payload.merge(mobile_money: mm.merge(provider: normalized_provider))
end
end
end
end
8 changes: 2 additions & 6 deletions lib/paystack_sdk/resources/customers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,9 @@ def list(per_page: 50, page: 1, **params)
validate_positive_integer!(value: per_page, name: "per_page", allow_nil: true)
validate_positive_integer!(value: page, name: "page", allow_nil: true)

if params[:from]
validate_date_format!(date_str: params[:from], name: "from")
end
validate_date_format!(date_str: params[:from], name: "from") if params[:from]

if params[:to]
validate_date_format!(date_str: params[:to], name: "to")
end
validate_date_format!(date_str: params[:to], name: "to") if params[:to]

query_params = {perPage: per_page, page: page}.merge(params)
response = @connection.get("customer", query_params)
Expand Down
16 changes: 7 additions & 9 deletions lib/paystack_sdk/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,7 @@ def initialize(response)
@error_message = @api_message || "Client error"

# Still raise for authentication issues as these are usually config problems
if @status_code == 401
raise AuthenticationError.new(@api_message || "Authentication failed")
end
raise AuthenticationError.new(@api_message || "Authentication failed") if @status_code == 401
when 429
# Rate limiting - raise as users need to implement retry logic
retry_after = response.headers["Retry-After"]
Expand Down Expand Up @@ -211,7 +209,7 @@ def key?(key)
# @yield [key, value] For hashes, passes each key-value pair
# @yield [value] For arrays, passes each item
# @return [Response, Enumerator] Self for chaining or Enumerator if no block given
def each(&block)
def each
return enum_for(:each) unless block_given?

if @raw_data.is_a?(Hash)
Expand All @@ -231,7 +229,7 @@ def each(&block)
# @return [Integer] The number of items
# @!method empty?
# @return [Boolean] Whether the collection is empty
[:size, :length, :count, :empty?].each do |method_name|
%i[size length count empty?].each do |method_name|
define_method(method_name) do
@raw_data.send(method_name) if @raw_data.respond_to?(method_name)
end
Expand All @@ -242,9 +240,10 @@ def each(&block)
# @return [Object, Response] The first item, wrapped if necessary
# @!method last
# @return [Object, Response] The last item, wrapped if necessary
[:first, :last].each do |method_name|
%i[first last].each do |method_name|
define_method(method_name) do
return nil unless @raw_data.is_a?(Array)

wrap_value(@raw_data.send(method_name))
end
end
Expand All @@ -261,9 +260,7 @@ def extract_identifier(body)

# First try to get identifier from the message
message = body["message"].to_s.downcase
if message =~ /with (id|code|reference|email): ([^\s]+)/i
return $2
end
return ::Regexp.last_match(2) if message =~ /with (id|code|reference|email): ([^\s]+)/i

# If not found in message, try to extract from error code
if body["code"]&.match?(/^(transaction|customer)_/)
Expand All @@ -288,6 +285,7 @@ def extract_api_message(body)
# @return [Hash, Array, nil] The data from the response
def extract_data_from_body(body)
return body unless body.is_a?(Hash)

body["data"] || body
end

Expand Down
44 changes: 24 additions & 20 deletions lib/paystack_sdk/validations.rb
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice refactors, sweet.

Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ module Validations
# @param name [String] Name of the parameter for error messages
# @raise [PaystackSdk::InvalidFormatError] If input is not a hash
def validate_hash!(input:, name: "Payload")
unless input.is_a?(Hash)
raise PaystackSdk::InvalidFormatError.new(name, "Hash")
end
return if input.is_a?(Hash)

raise PaystackSdk::InvalidFormatError.new(name, "Hash")
end

# Validates that required parameters are present in a payload.
Expand All @@ -53,10 +53,10 @@ def validate_required_params!(payload:, required_params:, operation_name: "Opera
!payload.key?(param) && !payload.key?(param.to_s)
end

unless missing_params.empty?
param = missing_params.first
raise PaystackSdk::MissingParamError.new(param)
end
return if missing_params.empty?

param = missing_params.first
raise PaystackSdk::MissingParamError.new(param)
end

# Validates that a value is present (not nil or empty).
Expand Down Expand Up @@ -91,9 +91,9 @@ def validate_positive_integer!(value:, name: "Parameter", allow_nil: true)
# @param name [String] Name of the parameter for error messages
# @raise [PaystackSdk::InvalidFormatError] If reference format is invalid
def validate_reference_format!(reference:, name: "Reference")
unless reference.to_s.match?(/^[a-zA-Z0-9._=-]+$/)
raise PaystackSdk::InvalidFormatError.new(name, "alphanumeric characters and the following: -, ., =")
end
return if reference.to_s.match?(/^[a-zA-Z0-9._=-]+$/)

raise PaystackSdk::InvalidFormatError.new(name, "alphanumeric characters and the following: -, ., =")
end

# Validates a date string format.
Expand All @@ -106,6 +106,7 @@ def validate_reference_format!(reference:, name: "Reference")
def validate_date_format!(date_str:, name: "Date", allow_nil: true)
if date_str.nil?
raise PaystackSdk::MissingParamError.new(name) unless allow_nil

return
end

Expand Down Expand Up @@ -136,13 +137,14 @@ def validate_date_format!(date_str:, name: "Date", allow_nil: true)
def validate_allowed_values!(value:, allowed_values:, name: "Parameter", allow_nil: true)
if value.nil?
raise PaystackSdk::MissingParamError.new(name) unless allow_nil

return
end

unless allowed_values.include?(value)
allowed_list = allowed_values.join(", ")
raise PaystackSdk::InvalidValueError.new(name, "must be one of: #{allowed_list}")
end
return if allowed_values.include?(value)

allowed_list = allowed_values.join(", ")
raise PaystackSdk::InvalidValueError.new(name, "must be one of: #{allowed_list}")
end

# Validates an email format.
Expand All @@ -154,12 +156,13 @@ def validate_allowed_values!(value:, allowed_values:, name: "Parameter", allow_n
def validate_email!(email:, name: "Email", allow_nil: false)
if email.nil?
raise PaystackSdk::MissingParamError.new(name) unless allow_nil

return
end

unless email.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
raise PaystackSdk::InvalidFormatError.new(name, "valid email address")
end
return if email.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)

raise PaystackSdk::InvalidFormatError.new(name, "valid email address")
end

# Validates a currency code format.
Expand All @@ -171,12 +174,13 @@ def validate_email!(email:, name: "Email", allow_nil: false)
def validate_currency!(currency:, name: "Currency", allow_nil: true)
if currency.nil?
raise PaystackSdk::MissingParamError.new(name) unless allow_nil

return
end

unless currency.to_s.match?(/\A[A-Z]{3}\z/)
raise PaystackSdk::InvalidFormatError.new(name, "3-letter ISO code (e.g., NGN, USD, GHS)")
end
return if currency.to_s.match?(/\A[A-Z]{3}\z/)

raise PaystackSdk::InvalidFormatError.new(name, "3-letter ISO code (e.g., NGN, USD, GHS)")
end

# Validates multiple fields at once.
Expand Down
6 changes: 3 additions & 3 deletions paystack_sdk.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ Gem::Specification.new do |spec|

# Dependencies
spec.add_dependency "faraday", "~> 2.13.1"
spec.add_development_dependency "rspec", "~> 3.13"
spec.add_development_dependency "standard", "~> 1.49.0"
spec.add_development_dependency "debug", "~> 1.9.0"
spec.add_development_dependency "irb", "~> 1.15.1"
spec.add_development_dependency "rake", "~> 13.2.1"
spec.add_development_dependency "debug", "~> 1.9.0"
spec.add_development_dependency "rspec", "~> 3.13"
spec.add_development_dependency "standard", "~> 1.49.0"

# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
Expand Down
9 changes: 9 additions & 0 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,13 @@
expect(client.transactions).to eq(transactions_double)
end
end

describe "#charges" do
it "returns an instance of Charges resource" do
charges_double = instance_double(PaystackSdk::Resources::Charges)
allow(PaystackSdk::Resources::Charges).to receive(:new).and_return(charges_double)

expect(client.charges).to eq(charges_double)
end
end
end
4 changes: 2 additions & 2 deletions spec/resources/banks_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
end

it "raises error for invalid currency" do
expect {
expect do
banks.list(currency: "INVALID")
}.to raise_error(PaystackSdk::InvalidValueError, /currency/)
end.to raise_error(PaystackSdk::InvalidValueError, /currency/)
end
end
end
Loading