From 0fc0e0270b0a3d45963178594748ef44d42186c4 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Sun, 2 Jul 2023 18:18:32 +0300 Subject: [PATCH] use cryptomus payment gateway --- app/admin/billing/accounts.rb | 10 +-- app/admin/billing/payments.rb | 29 ++++--- .../v1/cryptomus_payments_controller.rb | 4 + .../cryptomus_webhooks_controller.rb | 45 ++++++++++ app/decorators/payment_decorator.rb | 22 +++++ .../customer_api/cryptomus_payment_form.rb | 59 +++++++++++++ app/models/payment.rb | 87 +++++++++++++++++-- .../api/rest/admin/payment_resource.rb | 7 +- .../customer/v1/cryptomus_payment_resource.rb | 23 +++++ .../api/rest/customer/v1/payment_resource.rb | 6 ++ .../cryptomus_payment/check_status.rb | 28 ++++++ config/initializers/_config.rb | 5 ++ config/initializers/cryptomus.rb | 8 ++ config/routes.rb | 3 + .../20230702152539_payments_add_status_id.rb | 11 +++ db/structure.sql | 6 +- lib/cryptomus.rb | 24 +++++ lib/cryptomus/client.rb | 78 +++++++++++++++++ lib/cryptomus/configuration.rb | 16 ++++ lib/cryptomus/connection.rb | 61 +++++++++++++ lib/cryptomus/const.rb | 7 ++ lib/cryptomus/errors.rb | 24 +++++ lib/cryptomus/signature.rb | 15 ++++ lib/cryptomus/version.rb | 5 ++ lib/cryptomus/webhook_validator.rb | 18 ++++ spec/models/stats/customer_auth_stats_spec.rb | 18 ++++ 26 files changed, 591 insertions(+), 28 deletions(-) create mode 100644 app/controllers/api/rest/customer/v1/cryptomus_payments_controller.rb create mode 100644 app/controllers/cryptomus_webhooks_controller.rb create mode 100644 app/decorators/payment_decorator.rb create mode 100644 app/forms/customer_api/cryptomus_payment_form.rb create mode 100644 app/resources/api/rest/customer/v1/cryptomus_payment_resource.rb create mode 100644 app/services/cryptomus_payment/check_status.rb create mode 100644 config/initializers/cryptomus.rb create mode 100644 db/migrate/20230702152539_payments_add_status_id.rb create mode 100644 lib/cryptomus.rb create mode 100644 lib/cryptomus/client.rb create mode 100644 lib/cryptomus/configuration.rb create mode 100644 lib/cryptomus/connection.rb create mode 100644 lib/cryptomus/const.rb create mode 100644 lib/cryptomus/errors.rb create mode 100644 lib/cryptomus/signature.rb create mode 100644 lib/cryptomus/version.rb create mode 100644 lib/cryptomus/webhook_validator.rb diff --git a/app/admin/billing/accounts.rb b/app/admin/billing/accounts.rb index 44f121f6c..cf34c08ba 100644 --- a/app/admin/billing/accounts.rb +++ b/app/admin/billing/accounts.rb @@ -119,12 +119,10 @@ def scoped_collection end sidebar 'Create Payment', only: [:show] do - active_admin_form_for(Payment.new(account_id: params[:id]), - url: payment_account_path(params[:id]), - as: :payment, - method: :post) do |f| + active_admin_form_for(Payment.new, url: payment_account_path(params[:id]), as: :payment, method: :post) do |f| f.inputs do - f.input :account_id, as: :hidden + f.input :status_id, as: :hidden, value: Payment::CONST::STATUS_ID_COMPLETED + f.input :account_id, as: :hidden, value: params[:id] f.input :amount, input_html: { style: 'width: 200px' } f.input :notes, input_html: { style: 'width: 200px' } end @@ -291,7 +289,7 @@ def scoped_collection member_action :payment, method: :post do authorize! - payment_params = params.require(:payment).permit(:account_id, :amount, :notes) + payment_params = params.require(:payment).permit(:account_id, :amount, :notes, :status_id) payment = Payment.new(payment_params) if payment.save flash[:notice] = 'Payment created!' diff --git a/app/admin/billing/payments.rb b/app/admin/billing/payments.rb index 962a30f18..7f8dfe79f 100644 --- a/app/admin/billing/payments.rb +++ b/app/admin/billing/payments.rb @@ -6,7 +6,7 @@ config.batch_actions = false actions :index, :create, :new, :show - permit_params :account_id, :amount, :notes, :private_notes + permit_params :account_id, :amount, :notes, :private_notes, :status_id scope :all, default: true scope :today scope :yesterday @@ -17,7 +17,8 @@ [:account_name, proc { |row| row.account.try(:name) }], :amount, :notes, - :private_notes + :private_notes, + :status controller do def scoped_collection @@ -29,6 +30,7 @@ def scoped_collection f.semantic_errors *f.object.errors.attribute_names f.inputs form_title do + f.input :status_id, as: :hidden, value: Payment::CONST::STATUS_ID_COMPLETED f.account_input :account_id f.input :amount f.input :private_notes @@ -41,15 +43,16 @@ def scoped_collection id_column column :created_at column :account, footer: lambda { - strong do - 'Total:' - end - } + strong do + 'Total:' + end + } + column :status, :status_formatted, sortabla: :status_id column :amount, footer: lambda { - strong do - @footer_data[:total_amount] - end - } + strong do + @footer_data[:total_amount] + end + } column :private_notes column :notes column :uuid @@ -59,6 +62,11 @@ def scoped_collection filter :uuid_equals, label: 'UUID' filter :created_at, as: :date_time_range account_filter :account_id_eq + filter :status_id, + label: 'Status', + as: :select, + collection: proc { Payment::CONST::STATUS_IDS }, + input_html: { class: 'chosen' } filter :amount filter :private_notes filter :notes @@ -69,6 +77,7 @@ def scoped_collection row :uuid row :created_at row :account + row :status, :status_formatted row :amount row :private_notes row :notes diff --git a/app/controllers/api/rest/customer/v1/cryptomus_payments_controller.rb b/app/controllers/api/rest/customer/v1/cryptomus_payments_controller.rb new file mode 100644 index 000000000..7deb9ff68 --- /dev/null +++ b/app/controllers/api/rest/customer/v1/cryptomus_payments_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Api::Rest::Customer::V1::CryptomusPaymentsController < Api::Rest::Customer::V1::BaseController +end diff --git a/app/controllers/cryptomus_webhooks_controller.rb b/app/controllers/cryptomus_webhooks_controller.rb new file mode 100644 index 000000000..9c7235978 --- /dev/null +++ b/app/controllers/cryptomus_webhooks_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class CryptomusWebhooksController < ActionController::API + include CaptureError::ControllerMethods + + rescue_from StandardError, with: :handle_exceptions + rescue_from ActiveRecord::RecordNotFound, with: :handle_exceptions + + # https://doc.cryptomus.com/payments/webhook + def create + payload = params.except(:controller, :action, :sign).to_unsafe_h + sign = params[:sign] + + unless Cryptomus::WebhookValidator.validate(payload:, sign:) + Rails.logger.info { 'invalid signature' } + head 400 + return + end + + unless payload['is_final'] + Rails.logger.info { "status is not final: #{payload['status']}" } + head 204 + return + end + + payment = Payment.find(payload['order_id']) + CryptomusPayment::CheckStatus.call(payment:) + head 204 + end + + private + + def handle_exceptions(error) + log_error(error) + capture_error(error) + head 500 + end + + def capture_extra + { + payload: params.except(:controller, :action, :sign).to_unsafe_h, + sign: params[:sign] + } + end +end diff --git a/app/decorators/payment_decorator.rb b/app/decorators/payment_decorator.rb new file mode 100644 index 000000000..4b48fdcdf --- /dev/null +++ b/app/decorators/payment_decorator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentDecorator < BillingDecorator + delegate_all + decorates Payment + + def status_formatted + status_tag(status, class: status_color) + end + + private + + def status_color + if model.completed? + :ok + elsif model.pending? + :warning + elsif model.canceled? + :error + end + end +end diff --git a/app/forms/customer_api/cryptomus_payment_form.rb b/app/forms/customer_api/cryptomus_payment_form.rb new file mode 100644 index 000000000..c320b2fdf --- /dev/null +++ b/app/forms/customer_api/cryptomus_payment_form.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module CustomerApi + class CryptomusPaymentForm < ApplicationForm + EXPIRATION_SEC = 24 * 60 * 60 # expires in 24 hours + + def self.policy_class + PaymentPolicy + end + + attr_reader :id, :url + + attr_accessor :customer_id, :allowed_account_ids + + attribute :amount, :decimal + attribute :notes, :string + attribute :account_id, :integer + + validates :amount, presence: true + validates :amount, numericality: { greater_than_or_equal_to: 0.01 }, allow_nil: true + + validates :account, presence: true + + # @!method account + define_memoizable :account, apply: lambda { + return if account_id.nil? + + scope = Account.where(contractor_id: customer_id) + scope = scope.where(id: allowed_account_ids) if allowed_account_ids.present? + scope.find_by(uuid: account_id) + } + + def persisted? + id.present? + end + + private + + def _save + ApplicationRecord.transaction do + payment = Payment.create!( + account:, + amount:, + notes:, + status: Payment::CONST::STATUS_ID_PENDING + ) + crypto_payment = Cryptomus::Client.create_payment( + order_id: payment.id, + amount: amount.to_s, + currency: 'USD', + lifetime: EXPIRATION_SEC, + subtract: 100 # customer will pay 100% of commission + ) + @url = crypto_payment[:result][:url] + @id = payment.uuid + end + end + end +end diff --git a/app/models/payment.rb b/app/models/payment.rb index a9db4d072..efc23b5e9 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -11,6 +11,7 @@ # uuid :uuid not null # created_at :timestamptz not null # account_id :integer(4) not null +# status_id :integer(2) not null # # Indexes # @@ -23,19 +24,87 @@ # class Payment < ApplicationRecord - belongs_to :account + module CONST + STATUS_ID_CANCELED = 10 + STATUS_ID_COMPLETED = 20 + STATUS_ID_PENDING = 30 + STATUS_IDS = { + STATUS_ID_CANCELED => 'canceled', + STATUS_ID_COMPLETED => 'completed', + STATUS_ID_PENDING => 'pending' + }.freeze + + freeze + end include WithPaperTrail - validates :amount, numericality: true - validates :account, presence: true + belongs_to :account, class_name: 'Account' + + validates :amount, presence: true + validates :amount, numericality: { greater_than_or_equal_to: 0.01 }, allow_nil: true + + validates :status_id, presence: true + validates :status_id, inclusion: { in: CONST::STATUS_IDS.keys }, allow_nil: true + + before_save :top_up_balance + + # eq not_eq in not_in + scope :status_eq, lambda { |value| + status_id = CONST::STATUS_IDS.key(value) + status_id ? where(status_id:) : none + } + + scope :status_not_eq, lambda { |value| + status_id = CONST::STATUS_IDS.key(value) + status_id ? where.not(status_id:) : all + } + + scope :status_in, lambda { |values| + status_ids = values.map { |value| CONST::STATUS_IDS.key(value) }.compact + status_ids.present? ? where(status_id: status_ids) : none + } + + scope :status_not_in, lambda { |values| + status_ids = values.map { |value| CONST::STATUS_IDS.key(value) }.compact + status_ids.present? ? where.not(status_id: status_ids) : all + } + + scope :today, lambda { + where('created_at >= ? ', Time.now.at_beginning_of_day) + } + + scope :yesterday, lambda { + where('created_at >= ? and created_at < ?', 1.day.ago.at_beginning_of_day, Time.now.at_beginning_of_day) + } - before_create do - account.lock! # will generate SELECT FOR UPDATE SQL statement - account.balance += amount - throw(:abort) unless account.save + def status + CONST::STATUS_IDS[status_id] end - scope :today, -> { where('created_at >= ? ', Time.now.at_beginning_of_day) } - scope :yesterday, -> { where('created_at >= ? and created_at < ?', 1.day.ago.at_beginning_of_day, Time.now.at_beginning_of_day) } + def completed? + status_id == CONST::STATUS_ID_COMPLETED + end + + def canceled? + status_id == CONST::STATUS_ID_CANCELED + end + + def pending? + status_id == CONST::STATUS_ID_PENDING + end + + def self.ransackable_scopes(_auth_object = nil) + %i[status_eq status_not_eq status_in status_not_in] + end + + private + + def top_up_balance + if status_id_changed? && completed? + account.lock! # will generate SELECT FOR UPDATE SQL statement + account.balance += amount + throw(:abort) unless account.save + end + end end diff --git a/app/resources/api/rest/admin/payment_resource.rb b/app/resources/api/rest/admin/payment_resource.rb index 3b80453a4..baba393c4 100644 --- a/app/resources/api/rest/admin/payment_resource.rb +++ b/app/resources/api/rest/admin/payment_resource.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::Rest::Admin::PaymentResource < BaseResource - attributes :amount, :notes + attributes :amount, :notes, :status paginator :paged @@ -9,6 +9,7 @@ class Api::Rest::Admin::PaymentResource < BaseResource ransack_filter :amount, type: :number ransack_filter :notes, type: :string + ransack_filter :status, type: :enum, collection: Payment::CONST::STATUS_IDS.values def self.creatable_fields(_context) %i[ @@ -17,4 +18,8 @@ def self.creatable_fields(_context) notes ] end + + def self.sortable_fields(_context) + %i[amount notes] + end end diff --git a/app/resources/api/rest/customer/v1/cryptomus_payment_resource.rb b/app/resources/api/rest/customer/v1/cryptomus_payment_resource.rb new file mode 100644 index 000000000..d249c3e7d --- /dev/null +++ b/app/resources/api/rest/customer/v1/cryptomus_payment_resource.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Api::Rest::Customer::V1::CryptomusPaymentResource < Api::Rest::Customer::V1::BaseResource + model_name 'CustomerApi::CryptomusPaymentForm' + + attributes :amount, + :notes, + :url, + :payment_id + + has_one :account, foreign_key_on: :related + + before_create { _model.customer_id = context[:customer_id] } + before_create { _model.allowed_account_ids = context[:allowed_account_ids] } + + def self.creatable_fields(_ctx = nil) + %i[amount notes account] + end + + def fetchable_fields + %i[url] + end +end diff --git a/app/resources/api/rest/customer/v1/payment_resource.rb b/app/resources/api/rest/customer/v1/payment_resource.rb index 735c8eace..2f0f4dfaa 100644 --- a/app/resources/api/rest/customer/v1/payment_resource.rb +++ b/app/resources/api/rest/customer/v1/payment_resource.rb @@ -5,6 +5,7 @@ class Api::Rest::Customer::V1::PaymentResource < Api::Rest::Customer::V1::BaseRe attributes :amount, :notes, + :status, :created_at has_one :account, foreign_key_on: :related @@ -14,6 +15,7 @@ class Api::Rest::Customer::V1::PaymentResource < Api::Rest::Customer::V1::BaseRe ransack_filter :amount, type: :number ransack_filter :created_at, type: :datetime association_uuid_filter :account_id, class_name: 'Account' + ransack_filter :status, type: :enum, collection: Payment::CONST::STATUS_IDS.values def self.apply_allowed_accounts(records, options) context = options[:context] @@ -21,4 +23,8 @@ def self.apply_allowed_accounts(records, options) records = records.where(account_id: context[:allowed_account_ids]) if context[:allowed_account_ids].present? records end + + def self.sortable_fields(_context) + %i[amount notes created_at] + end end diff --git a/app/services/cryptomus_payment/check_status.rb b/app/services/cryptomus_payment/check_status.rb new file mode 100644 index 000000000..0bb62e9b5 --- /dev/null +++ b/app/services/cryptomus_payment/check_status.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module CryptomusPayment + class CheckStatus < ApplicationService + Error = Class.new(StandardError) + + parameter :payment, required: true + + SUCCESS_STATUSES = %w[paid paid_over].freeze + + def call + payment.with_lock do + raise Error, 'Payment is not pending' unless payment.pending? + + cr_payment = Cryptomus::Client.payment(order_id: payment.id) + unless cr_payment[:result][:is_final] + raise Error, "Cryptomus Payment status is not final: #{cr_payment[:result][:status]}" + end + + if cr_payment[:result][:status].in?(SUCCESS_STATUSES) + payment.update!(status_id: Payment::CONST::STATUS_ID_COMPLETED) + else + payment.update!(status_id: Payment::CONST::STATUS_ID_CANCELED) + end + end + end + end +end diff --git a/config/initializers/_config.rb b/config/initializers/_config.rb index 19b690933..e499bcf99 100644 --- a/config/initializers/_config.rb +++ b/config/initializers/_config.rb @@ -68,6 +68,11 @@ def self.setting_files(config_root, _env) optional(:keep_expired_destinations_days) optional(:keep_expired_dialpeers_days) optional(:keep_balance_notifications_days) + + optional(:cryptomus).schema do + optional(:api_key).maybe(:string) + optional(:merchant_id).maybe(:string) + end end end diff --git a/config/initializers/cryptomus.rb b/config/initializers/cryptomus.rb new file mode 100644 index 000000000..16b6ff749 --- /dev/null +++ b/config/initializers/cryptomus.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'cryptomus' + +Cryptomus.configure do |config| + config.merchant_id = YetiConfig.cryptomus&.merchant_id + config.api_key = YetiConfig.cryptomus&.api_key +end diff --git a/config/routes.rb b/config/routes.rb index 4ed18eac6..2b3d0c27b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,8 @@ def dasherized_resources(name, options = {}, &block) get 'with_contractor_accounts', to: 'accounts#with_contractor' ActiveAdmin.routes(self) + post 'cryptomus_webhooks', to: 'cryptomus_webhooks#create' + resources :active_calls, constraints: { id: %r{[^/]+} }, only: %i[show index destroy] resources :remote_stats do @@ -166,6 +168,7 @@ def dasherized_resources(name, options = {}, &block) jsonapi_resources :chart_active_calls, only: %i[create] jsonapi_resources :chart_originated_cps, only: %i[create] jsonapi_resources :payments, only: %i[index show] + jsonapi_resources :cryptomus_payments, only: %i[create] jsonapi_resources :invoices, only: %i[index show] do jsonapi_relationships member { get :download } diff --git a/db/migrate/20230702152539_payments_add_status_id.rb b/db/migrate/20230702152539_payments_add_status_id.rb new file mode 100644 index 000000000..f407f3195 --- /dev/null +++ b/db/migrate/20230702152539_payments_add_status_id.rb @@ -0,0 +1,11 @@ +class PaymentsAddStatusId < ActiveRecord::Migration[7.0] + def up + # Payment::CONST::STATUS_ID_COMPLETED == 20 + add_column 'billing.payments', :status_id, :integer, limit: 2, default: 20, null: false + change_column_default 'billing.payments', :status_id, nil + end + + def down + remove_column 'billing.payments', :status_id + end +end diff --git a/db/structure.sql b/db/structure.sql index 2ab0be5cf..befa82b39 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -22257,7 +22257,8 @@ CREATE TABLE billing.payments ( id bigint NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, uuid uuid DEFAULT public.uuid_generate_v1() NOT NULL, - private_notes character varying + private_notes character varying, + status_id smallint NOT NULL ); @@ -31441,6 +31442,7 @@ INSERT INTO "public"."schema_migrations" (version) VALUES ('20230518130328'), ('20230524191752'), ('20230602113601'), -('20230608134717'); +('20230608134717'), +('20230702152539'); diff --git a/lib/cryptomus.rb b/lib/cryptomus.rb new file mode 100644 index 000000000..297193f06 --- /dev/null +++ b/lib/cryptomus.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative 'cryptomus/version' +require_relative 'cryptomus/const' +require_relative 'cryptomus/errors' +require_relative 'cryptomus/configuration' +require_relative 'cryptomus/signature' +require_relative 'cryptomus/connection' +require_relative 'cryptomus/client' +require_relative 'cryptomus/webhook_validator' + +# Cryptomus Crypto Payment Gateway SDK +# [https://doc.cryptomus.com] +module Cryptomus + module_function + + def configure + yield config + end + + def config + @config ||= Configuration.new + end +end diff --git a/lib/cryptomus/client.rb b/lib/cryptomus/client.rb new file mode 100644 index 000000000..c98f86143 --- /dev/null +++ b/lib/cryptomus/client.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Cryptomus + module Client + module_function + + # https://doc.cryptomus.com/payments/creating-invoice + # @param attributes [Hash] + # @option attributes [String] :order_id required + # @option attributes [String] :currency required + # @option attributes [String] :amount required + # @return [Hash] + # @raise [Cryptomus::Errors::ApiError] + # @example + # Cryptomus::Client.create_invoice( + # order_id: '123', + # currency: 'USD', + # amount: '100' + # ) + def create_payment(attributes) + connection.post('/v1/payment', body: attributes) + end + + # https://doc.cryptomus.com/payments/payment-history + # @param cursor [String,nil] from previous response paginate.nextCursor or paginate.previousCursor + # @param date_from [String,nil] format: 'YYYY-MM-DD H:mm:ss' + # @param date_to [String,nil] format: 'YYYY-MM-DD H:mm:ss' + # @return [Hash] + # @raise [Cryptomus::Errors::ApiError] + def list_payments(cursor: nil, date_from: nil, date_to: nil) + attributes = { date_from:, date_to: }.compact + attributes = nil if attributes.empty? + query = { cursor: cursor }.compact + query = nil if query.empty? + connection.post('/v1/payment/list', body: attributes, query:) + end + + # https://doc.cryptomus.com/payments/payment-information + # @param uuid [String,nil] uuid or order_id must be passed. + # @param order_id [String,nil] uuid or order_id must be passed. + # @return [Hash] + # @raise [Cryptomus::Errors::ApiError] + def payment(uuid: nil, order_id: nil) + attributes = { uuid:, order_id: }.compact + connection.get('/v1/payment/info', body: attributes) + end + + # https://doc.cryptomus.com/payments/creating-static + # @param attributes [Hash] + # @option attributes [String] :order_id required + # @option attributes [String] :currency required + # @option attributes [String] :network required + # @return [Hash] + # @raise [Cryptomus::Errors::ApiError] + # @example + # Cryptomus::Client.create_wallet( + # order_id: '123', + # currency: 'USDT', + # network: 'tron' + # ) + def create_wallet(attributes) + connection.post('/v1/wallet', body: attributes) + end + + # https://doc.cryptomus.com/balance + # @return [Hash] + # @raise [Cryptomus::Errors::ApiError] + # @example + # Cryptomus::Client.balance + def balance + connection.post('/v1/balance') + end + + def connection + @connection ||= Connection.new + end + end +end diff --git a/lib/cryptomus/configuration.rb b/lib/cryptomus/configuration.rb new file mode 100644 index 000000000..426e5a6da --- /dev/null +++ b/lib/cryptomus/configuration.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Cryptomus + class Configuration + attr_accessor :api_key, + :merchant_id, + :user_agent, + :connection_config, + :on_error, + :logger + + def initialize + self.user_agent = "Cryptomus Ruby SDK #{Cryptomus::VERSION} (Ruby #{RUBY_VERSION})" + end + end +end diff --git a/lib/cryptomus/connection.rb b/lib/cryptomus/connection.rb new file mode 100644 index 000000000..7608b732c --- /dev/null +++ b/lib/cryptomus/connection.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'faraday' +require 'digest' +require 'base64' + +module Cryptomus + class Connection + MIME_TYPE = 'application/json' + + # @param path [String] + # @param body [Hash, nil] + # @raise [Cryptomus::Errors::ApiError] + def post(path, body: nil, query: nil) + response = connection.post do |req| + req.url(path, query) + raw_body = body&.to_json + req.body = raw_body + req[:sign] = Signature.generate(raw_body) + end + handle_response(response) + response.body + end + + private + + def connection + Faraday.new(connection_options) do |builder| + builder.adapter Faraday.default_adapter + builder.request :json + builder.response :json, parser_options: { symbolize_names: true } + builder.response :logger, Cryptomus.config.logger, bodies: true if Cryptomus.config.logger + Cryptomus.config.connection_config&.call(builder) + end + end + + def handle_response(response) + return if response.success? + return if Cryptomus.config.handle_response&.call(response) + + raise Cryptomus::Errors::ApiError, response + end + + # Create MD5 signature from raw body in base64 encoded format and api key + def generate_sign(raw_body) + raw_body_encoded = Base64.encode64(raw_body || '') + Digest::MD5.hexdigest("#{raw_body_encoded}#{Cryptomus.config.api_key}") + end + + def connection_options + { + url: Cryptomus::CONST::URL, + headers: { + 'Content-Type' => MIME_TYPE, + merchant: Cryptomus.config.merchant_id, + user_agent: Cryptomus.config.user_agent + } + } + end + end +end diff --git a/lib/cryptomus/const.rb b/lib/cryptomus/const.rb new file mode 100644 index 000000000..904652737 --- /dev/null +++ b/lib/cryptomus/const.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Cryptomus + module CONST + URL = 'https://api.cryptomus.com' + end +end diff --git a/lib/cryptomus/errors.rb b/lib/cryptomus/errors.rb new file mode 100644 index 000000000..2b9b772c9 --- /dev/null +++ b/lib/cryptomus/errors.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Cryptomus + module Errors + class Error < StandardError + end + + class ApiError < Error + # @!method response [Faraday::Response,nil] + # @!method status [Integer,nil] + # @!method response_body [Hash,nil] + attr_reader :response, :status, :response_body + + # @param msg [String] + # @param response [Faraday::Response,nil] + def initialize(msg, response = nil) + @response = response + @status = response&.status + @response_body = response&.body + super(msg) + end + end + end +end diff --git a/lib/cryptomus/signature.rb b/lib/cryptomus/signature.rb new file mode 100644 index 000000000..65dd868be --- /dev/null +++ b/lib/cryptomus/signature.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Cryptomus + module Signature + module_function + + # https://doc.cryptomus.com/getting-started/request-format + # @param raw_body [String] + # @return [String] + def generate(raw_body) + raw_body_encoded = Base64.encode64(raw_body || '') + Digest::MD5.hexdigest("#{raw_body_encoded}#{Cryptomus.config.api_key}") + end + end +end diff --git a/lib/cryptomus/version.rb b/lib/cryptomus/version.rb new file mode 100644 index 000000000..dcf9fc27b --- /dev/null +++ b/lib/cryptomus/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Cryptomus + VERSION = '0.1.0' +end diff --git a/lib/cryptomus/webhook_validator.rb b/lib/cryptomus/webhook_validator.rb new file mode 100644 index 000000000..52318009f --- /dev/null +++ b/lib/cryptomus/webhook_validator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Cryptomus + module WebhookValidator + module_function + + # https://doc.cryptomus.com/payments/webhook + # @param payload [Hash] + # @param sign [String] + # @return [Boolean] + def validate(payload:, sign:) + payload_json = JSON.generate(payload) + payload_json_encoded = Base64.encode64(payload_json) + md5 = Digest::MD5.hexdigest("#{payload_json_encoded}#{Cryptomus.config.api_key}") + md5 == sign + end + end +end diff --git a/spec/models/stats/customer_auth_stats_spec.rb b/spec/models/stats/customer_auth_stats_spec.rb index 645da32a8..9fa015377 100644 --- a/spec/models/stats/customer_auth_stats_spec.rb +++ b/spec/models/stats/customer_auth_stats_spec.rb @@ -1,5 +1,23 @@ # frozen_string_literal: true +# == Schema Information +# +# Table name: stats.customer_auth_stats +# +# id :bigint(8) not null, primary key +# calls_count :integer(4) default(0), not null +# customer_duration :integer(4) default(0), not null +# customer_price :decimal(, ) default(0.0), not null +# customer_price_no_vat :decimal(, ) default(0.0), not null +# duration :integer(4) default(0), not null +# timestamp :timestamptz not null +# vendor_price :decimal(, ) default(0.0), not null +# customer_auth_id :integer(4) not null +# +# Indexes +# +# customer_auth_stats_customer_auth_id_timestamp_idx (customer_auth_id,timestamp) UNIQUE +# RSpec.describe Stats::CustomerAuthStats, type: :model do describe '.last24_hour', freeze_time: true do subject { described_class.last24_hour }