diff --git a/bin/console b/bin/console index fc775ad..3e28b27 100755 --- a/bin/console +++ b/bin/console @@ -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__) diff --git a/lib/paystack_sdk/client.rb b/lib/paystack_sdk/client.rb index 514a61d..1de59e1 100644 --- a/lib/paystack_sdk/client.rb +++ b/lib/paystack_sdk/client.rb @@ -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 @@ -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 diff --git a/lib/paystack_sdk/resources/charges.rb b/lib/paystack_sdk/resources/charges.rb new file mode 100644 index 0000000..1920533 --- /dev/null +++ b/lib/paystack_sdk/resources/charges.rb @@ -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 diff --git a/lib/paystack_sdk/resources/customers.rb b/lib/paystack_sdk/resources/customers.rb index 9fecdb9..c112fa4 100644 --- a/lib/paystack_sdk/resources/customers.rb +++ b/lib/paystack_sdk/resources/customers.rb @@ -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) diff --git a/lib/paystack_sdk/response.rb b/lib/paystack_sdk/response.rb index 87fdba8..9aaf979 100644 --- a/lib/paystack_sdk/response.rb +++ b/lib/paystack_sdk/response.rb @@ -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"] @@ -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) @@ -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 @@ -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 @@ -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)_/) @@ -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 diff --git a/lib/paystack_sdk/validations.rb b/lib/paystack_sdk/validations.rb index 9a1276d..d6a3bbc 100644 --- a/lib/paystack_sdk/validations.rb +++ b/lib/paystack_sdk/validations.rb @@ -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. @@ -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). @@ -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. @@ -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 @@ -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. @@ -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. @@ -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. diff --git a/paystack_sdk.gemspec b/paystack_sdk.gemspec index dbee9af..9a4d08e 100644 --- a/paystack_sdk.gemspec +++ b/paystack_sdk.gemspec @@ -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 diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 5396297..3eb97d8 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -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 diff --git a/spec/resources/banks_spec.rb b/spec/resources/banks_spec.rb index d5b9a33..73a9ba7 100644 --- a/spec/resources/banks_spec.rb +++ b/spec/resources/banks_spec.rb @@ -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 diff --git a/spec/resources/charges_spec.rb b/spec/resources/charges_spec.rb new file mode 100644 index 0000000..23b6959 --- /dev/null +++ b/spec/resources/charges_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +RSpec.describe PaystackSdk::Resources::Charges do + let(:connection) { instance_double("PaystackSdk::Connection") } + let(:charges) { described_class.new(connection) } + + describe "#mobile_money" do + let(:payload) do + { + email: "customer@email.com", + amount: 10_000, + currency: "GHS", + mobile_money: { + phone: "0551234987", + provider: "mtn" + } + } + end + + it "creates a mobile money charge" do + response_double = double("Response", success?: true, status: "pay_offline") + expect(connection).to receive(:post) + .with("/charge", payload) + .and_return(response_double) + expect(PaystackSdk::Response).to receive(:new).with(response_double) + .and_return(response_double) + + response = charges.mobile_money(payload) + + expect(response.success?).to be true + expect(response.status).to eq("pay_offline") + end + + it "accepts provider codes in different cases and normalizes before POST" do + payload[:mobile_money][:provider] = "MTN" + normalized_payload = payload.merge( + mobile_money: payload[:mobile_money].merge(provider: "mtn") + ) + response_double = double("Response", success?: true) + expect(connection).to receive(:post) + .with("/charge", normalized_payload) + .and_return(response_double) + expect(PaystackSdk::Response).to receive(:new).with(response_double) + .and_return(response_double) + + expect { charges.mobile_money(payload) }.not_to raise_error + end + + it "raises an error when mobile_money provider is invalid" do + payload[:mobile_money][:provider] = "invalid" + + expect { charges.mobile_money(payload) } + .to raise_error(PaystackSdk::InvalidValueError, /mobile_money provider/) + end + + it "raises an error when required mobile_money details are missing" do + payload[:mobile_money].delete(:phone) + + expect { charges.mobile_money(payload) } + .to raise_error(PaystackSdk::MissingParamError, /mobile_money phone/) + end + end + + describe "#submit_otp" do + let(:otp_payload) { {otp: "123456", reference: "r13havfcdt7btcm"} } + + it "submits the otp for a charge" do + response_double = double("Response", success?: true, status: "success") + expect(connection).to receive(:post) + .with("/charge/submit_otp", otp_payload) + .and_return(response_double) + expect(PaystackSdk::Response).to receive(:new).with(response_double) + .and_return(response_double) + + response = charges.submit_otp(otp_payload) + expect(response.status).to eq("success") + end + + it "raises an error when otp is missing" do + otp_payload.delete(:otp) + + expect { charges.submit_otp(otp_payload) } + .to raise_error(PaystackSdk::MissingParamError, /otp/) + end + end +end diff --git a/spec/resources/transactions_spec.rb b/spec/resources/transactions_spec.rb index 36cfed4..3d8796e 100644 --- a/spec/resources/transactions_spec.rb +++ b/spec/resources/transactions_spec.rb @@ -8,7 +8,7 @@ let(:params) do { email: "customer@email.com", - amount: 10000, + amount: 10_000, currency: "GHS" } end @@ -133,14 +133,14 @@ { authorization_code: "AUTH_72btv547", email: "customer@email.com", - amount: 10000 + amount: 10_000 } end context "with successful response" do it "charges the authorization" do response_double = double("Response", success?: true, status: "success", - api_message: "Charge attempted", amount: 35247, reference: "0m7frfnr47ezyxl", + api_message: "Charge attempted", amount: 35_247, reference: "0m7frfnr47ezyxl", authorization: double("Authorization", authorization_code: "AUTH_uh8bcl3zbn"), customer: double("Customer", email: "customer@email.com")) expect(connection).to receive(:post) @@ -154,7 +154,7 @@ expect(response.success?).to be true expect(response.status).to be "success" expect(response.api_message).to eq("Charge attempted") - expect(response.amount).to eq(35247) + expect(response.amount).to eq(35_247) expect(response.reference).to eq("0m7frfnr47ezyxl") expect(response.authorization.authorization_code).to eq("AUTH_uh8bcl3zbn") expect(response.customer.email).to eq("customer@email.com") @@ -193,7 +193,7 @@ { authorization_code: "AUTH_72btv547", currency: "NGN", - amount: 10000, + amount: 10_000, email: "customer@email.com" } end diff --git a/spec/resources/transfer_recipients_spec.rb b/spec/resources/transfer_recipients_spec.rb index 07cf38f..cad0396 100644 --- a/spec/resources/transfer_recipients_spec.rb +++ b/spec/resources/transfer_recipients_spec.rb @@ -22,9 +22,9 @@ end it "raises error for missing required params" do - expect { + expect do recipients.create({}) - }.to raise_error(PaystackSdk::MissingParamError) + end.to raise_error(PaystackSdk::MissingParamError) end end diff --git a/spec/resources/transfers_spec.rb b/spec/resources/transfers_spec.rb index 6b5713e..1bbcfe3 100644 --- a/spec/resources/transfers_spec.rb +++ b/spec/resources/transfers_spec.rb @@ -6,7 +6,7 @@ let(:params) do { source: "balance", - amount: 50000, + amount: 50_000, recipient: "RCP_1234567890", reason: "Test transfer" } @@ -23,9 +23,9 @@ end it "raises error for missing required params" do - expect { + expect do transfers.create({}) - }.to raise_error(PaystackSdk::MissingParamError) + end.to raise_error(PaystackSdk::MissingParamError) end end diff --git a/spec/resources/verification_spec.rb b/spec/resources/verification_spec.rb index 6c76a04..2e728c6 100644 --- a/spec/resources/verification_spec.rb +++ b/spec/resources/verification_spec.rb @@ -15,15 +15,15 @@ end it "raises error for missing account_number" do - expect { + expect do verification.resolve_account(account_number: nil, bank_code: "058") - }.to raise_error(PaystackSdk::MissingParamError, /account_number/) + end.to raise_error(PaystackSdk::MissingParamError, /account_number/) end it "raises error for missing bank_code" do - expect { + expect do verification.resolve_account(account_number: "0001234567", bank_code: nil) - }.to raise_error(PaystackSdk::MissingParamError, /bank_code/) + end.to raise_error(PaystackSdk::MissingParamError, /bank_code/) end end @@ -38,9 +38,9 @@ end it "raises error for missing bin" do - expect { + expect do verification.resolve_card_bin(nil) - }.to raise_error(PaystackSdk::MissingParamError, /bin/) + end.to raise_error(PaystackSdk::MissingParamError, /bin/) end end @@ -69,9 +69,9 @@ %i[account_number account_name account_type bank_code country_code document_type].each do |field| params = required_params.dup params.delete(field) - expect { + expect do verification.validate_account(params) - }.to raise_error(PaystackSdk::MissingParamError, /#{field}/) + end.to raise_error(PaystackSdk::MissingParamError, /#{field}/) end end