cryptocoin_payable 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.rspec +1 -0
- data/.rubocop.yml +36 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +22 -0
- data/README.md +237 -0
- data/Rakefile +3 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/cryptocoin_payable.gemspec +39 -0
- data/features/coin_payments.feature +51 -0
- data/features/default.feature +5 -0
- data/features/pricing_processor.feature +4 -0
- data/features/step_definitions/coin_payment_steps.rb +45 -0
- data/features/step_definitions/currency_conversion_steps.rb +12 -0
- data/features/step_definitions/model_step.rb +11 -0
- data/features/step_definitions/processor_steps.rb +7 -0
- data/features/step_definitions/widget_steps.rb +3 -0
- data/features/support/env.rb +26 -0
- data/lib/cryptocoin_payable/adapters/base.rb +99 -0
- data/lib/cryptocoin_payable/adapters/bitcoin.rb +93 -0
- data/lib/cryptocoin_payable/adapters/bitcoin_cash.rb +34 -0
- data/lib/cryptocoin_payable/adapters/ethereum.rb +77 -0
- data/lib/cryptocoin_payable/adapters.rb +27 -0
- data/lib/cryptocoin_payable/coin_payment.rb +141 -0
- data/lib/cryptocoin_payable/coin_payment_transaction.rb +5 -0
- data/lib/cryptocoin_payable/commands/payment_processor.rb +67 -0
- data/lib/cryptocoin_payable/commands/pricing_processor.rb +43 -0
- data/lib/cryptocoin_payable/config.rb +65 -0
- data/lib/cryptocoin_payable/currency_conversion.rb +11 -0
- data/lib/cryptocoin_payable/errors.rb +7 -0
- data/lib/cryptocoin_payable/has_coin_payments.rb +15 -0
- data/lib/cryptocoin_payable/tasks.rb +20 -0
- data/lib/cryptocoin_payable/version.rb +3 -0
- data/lib/cryptocoin_payable.rb +18 -0
- data/lib/generators/cryptocoin_payable/install_generator.rb +27 -0
- data/lib/generators/cryptocoin_payable/templates/create_coin_payment_transactions.rb +16 -0
- data/lib/generators/cryptocoin_payable/templates/create_coin_payments.rb +19 -0
- data/lib/generators/cryptocoin_payable/templates/create_currency_conversions.rb +11 -0
- data/spec/acceptance/adapters/bitcoin_cash_spec.rb +54 -0
- data/spec/acceptance/adapters/bitcoin_spec.rb +79 -0
- data/spec/acceptance/adapters/ethereum_spec.rb +77 -0
- data/spec/dummy/README.rdoc +28 -0
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/assets/images/.keep +0 -0
- data/spec/dummy/app/assets/javascripts/application.js +13 -0
- data/spec/dummy/app/assets/stylesheets/application.css +13 -0
- data/spec/dummy/app/controllers/application_controller.rb +5 -0
- data/spec/dummy/app/controllers/concerns/.keep +0 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/mailers/.keep +0 -0
- data/spec/dummy/app/models/.keep +0 -0
- data/spec/dummy/app/models/concerns/.keep +0 -0
- data/spec/dummy/app/models/widget.rb +3 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/bin/bundle +3 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/config/application.rb +22 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +25 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +29 -0
- data/spec/dummy/config/environments/production.rb +80 -0
- data/spec/dummy/config/environments/test.rb +36 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/cryptocoin_payable.rb +23 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/secret_token.rb +14 -0
- data/spec/dummy/config/initializers/session_store.rb +3 -0
- data/spec/dummy/config/initializers/state_machine.rb +10 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/locales/en.yml +23 -0
- data/spec/dummy/config/routes.rb +56 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/db/development.sqlite3 +0 -0
- data/spec/dummy/db/migrate/20140510023211_create_widgets.rb +5 -0
- data/spec/dummy/db/migrate/20171227225132_create_coin_payments.rb +19 -0
- data/spec/dummy/db/migrate/20171227225133_create_coin_payment_transactions.rb +16 -0
- data/spec/dummy/db/migrate/20171227225134_create_currency_conversions.rb +11 -0
- data/spec/dummy/db/schema.rb +54 -0
- data/spec/dummy/lib/assets/.keep +0 -0
- data/spec/dummy/log/.keep +0 -0
- data/spec/dummy/public/404.html +58 -0
- data/spec/dummy/public/422.html +58 -0
- data/spec/dummy/public/500.html +57 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/dummy/test/fixtures/widgets.yml +11 -0
- data/spec/dummy/test/models/widget_test.rb +7 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/gets_an_empty_result_when_no_transactions_found.yml +67 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/gets_transactions_for_a_given_address.yml +73 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/raises_an_error_when_an_invalid_address_is_passed.yml +103 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/when_the_Block_Explorer_API_fails/falls_back_to_using_the_BlockCypher_API.yml +171 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_BitcoinCash/gets_an_empty_result_when_no_transactions_found.yml +62 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_BitcoinCash/gets_transactions_for_a_given_address.yml +65 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Ethereum/gets_an_empty_result_when_no_transactions_found.yml +44 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Ethereum/gets_transactions_for_a_given_address.yml +44 -0
- data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Ethereum/raises_an_error_when_an_invalid_address_is_passed.yml +44 -0
- data/spec/spec_helper.rb +112 -0
- metadata +428 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module CryptocoinPayable
|
|
2
|
+
module Adapters
|
|
3
|
+
class Bitcoin < Base
|
|
4
|
+
# Satoshi in Bitcoin
|
|
5
|
+
def self.subunit_in_main
|
|
6
|
+
100_000_000
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.coin_symbol
|
|
10
|
+
'BTC'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch_transactions(address)
|
|
14
|
+
fetch_block_explorer_transactions(address)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
fetch_block_cypher_transactions(address)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_address(id)
|
|
20
|
+
super.to_address(network: network)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def prefix
|
|
26
|
+
CryptocoinPayable.configuration.testnet ? 'testnet.' : ''
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def network
|
|
30
|
+
CryptocoinPayable.configuration.testnet ? :bitcoin_testnet : :bitcoin
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_total_tx_value_block_explorer(output_transactions, address)
|
|
34
|
+
output_transactions
|
|
35
|
+
.select { |out| out['scriptPubKey']['addresses'].try('include?', address) }
|
|
36
|
+
.sum { |out| (out['value'].to_f * self.class.subunit_in_main).to_i }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def fetch_block_explorer_transactions(address)
|
|
40
|
+
url = "https://#{prefix}blockexplorer.com/api/txs/?address=#{address}"
|
|
41
|
+
parse_block_explorer_transactions(get_request(url).body, address)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse_block_explorer_transactions(response, address)
|
|
45
|
+
json = JSON.parse(response)
|
|
46
|
+
json['txs'].map { |tx| convert_block_explorer_transactions(tx, address) }
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
raise ApiError, response
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def convert_block_explorer_transactions(transaction, address)
|
|
52
|
+
{
|
|
53
|
+
tx_hash: transaction['txid'],
|
|
54
|
+
block_hash: transaction['blockhash'],
|
|
55
|
+
block_time: transaction['blocktime'].nil? ? nil : parse_timestamp(transaction['blocktime']),
|
|
56
|
+
estimated_tx_time: parse_timestamp(transaction['time']),
|
|
57
|
+
estimated_tx_value: parse_total_tx_value_block_explorer(transaction['vout'], address),
|
|
58
|
+
confirmations: transaction['confirmations']
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_total_tx_value_block_cypher(output_transactions, address)
|
|
63
|
+
output_transactions
|
|
64
|
+
.sum { |out| out['addresses'].join.eql?(address) ? out['value'] : 0 }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_block_cypher_transactions(address)
|
|
68
|
+
url = "https://api.blockcypher.com/v1/btc/main/addrs/#{address}/full"
|
|
69
|
+
parse_block_cypher_transactions(get_request(url).body, address)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_block_cypher_transactions(response, address)
|
|
73
|
+
json = JSON.parse(response)
|
|
74
|
+
raise ApiError, json['error'] if json['error']
|
|
75
|
+
|
|
76
|
+
json['txs'].map { |tx| convert_block_cypher_transactions(tx, address) }
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
raise ApiError, response
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def convert_block_cypher_transactions(transaction, address)
|
|
82
|
+
{
|
|
83
|
+
tx_hash: transaction['hash'],
|
|
84
|
+
block_hash: transaction['block_hash'],
|
|
85
|
+
block_time: parse_time(transaction['confirmed']),
|
|
86
|
+
estimated_tx_time: parse_time(transaction['received']),
|
|
87
|
+
estimated_tx_value: parse_total_tx_value_block_cypher(transaction['outputs'], address),
|
|
88
|
+
confirmations: transaction['confirmations'].to_i
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'cash_addr'
|
|
2
|
+
|
|
3
|
+
module CryptocoinPayable
|
|
4
|
+
module Adapters
|
|
5
|
+
class BitcoinCash < Bitcoin
|
|
6
|
+
def self.coin_symbol
|
|
7
|
+
'BCH'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def fetch_transactions(address)
|
|
11
|
+
raise NetworkNotSupported if CryptocoinPayable.configuration.testnet
|
|
12
|
+
|
|
13
|
+
url = "https://#{prefix}blockexplorer.com/api/txs/?address=#{legacy_address(address)}"
|
|
14
|
+
parse_block_explorer_transactions(get_request(url).body, address)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_address(id)
|
|
18
|
+
CashAddr::Converter.to_cash_address(super)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def legacy_address(address)
|
|
24
|
+
CashAddr::Converter.to_legacy_address(address)
|
|
25
|
+
rescue CashAddr::InvalidAddress
|
|
26
|
+
raise ApiError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def prefix
|
|
30
|
+
CryptocoinPayable.configuration.testnet ? 'bchtest.' : 'bitcoincash.'
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'eth'
|
|
2
|
+
|
|
3
|
+
module CryptocoinPayable
|
|
4
|
+
module Adapters
|
|
5
|
+
class Ethereum < Base
|
|
6
|
+
# Wei in Ether
|
|
7
|
+
def self.subunit_in_main
|
|
8
|
+
1_000_000_000_000_000_000
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.coin_symbol
|
|
12
|
+
'ETH'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fetch_transactions(address)
|
|
16
|
+
api_adapter_key = coin_config.try(:adapter_api_key)
|
|
17
|
+
url = "https://#{subdomain}.etherscan.io/api?module=account&action=txlist&address=#{address}&tag=latest"
|
|
18
|
+
url += '?apiKey=' + api_adapter_key if api_adapter_key
|
|
19
|
+
|
|
20
|
+
response = get_request(url)
|
|
21
|
+
json = JSON.parse(response.body)
|
|
22
|
+
|
|
23
|
+
raise ApiError, json['message'] if json['status'] == '0' && json['message'] == 'NOTOK'
|
|
24
|
+
|
|
25
|
+
json['result'].map { |tx| convert_transactions(tx, address) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_address(id)
|
|
29
|
+
Eth::Utils.public_key_to_address(super.public_key.uncompressed.to_hex)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def subdomain
|
|
35
|
+
@subdomain ||= CryptocoinPayable.configuration.testnet ? 'rinkeby' : 'api'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Example response:
|
|
39
|
+
# {
|
|
40
|
+
# status: "1",
|
|
41
|
+
# message: "OK",
|
|
42
|
+
# result: [
|
|
43
|
+
# {
|
|
44
|
+
# blockNumber: "4790248",
|
|
45
|
+
# timeStamp: "1514144760",
|
|
46
|
+
# hash: "0x52345400e42a15ba883fb0e314d050a7e7e376a30fc59dfcd7b841007d5d710c",
|
|
47
|
+
# nonce: "215964",
|
|
48
|
+
# block_hash: "0xe6ed0d98586cae04be57e515ca7773c020b441de60a467cd2773877a8996916f",
|
|
49
|
+
# transactionIndex: "4",
|
|
50
|
+
# from: "0xd24400ae8bfebb18ca49be86258a3c749cf46853",
|
|
51
|
+
# to: "0x911f9d574d1ca099cae5ab606aa9207fe238579f",
|
|
52
|
+
# value: "10000000000000000",
|
|
53
|
+
# gas: "90000",
|
|
54
|
+
# gasPrice: "28000000000",
|
|
55
|
+
# isError: "0",
|
|
56
|
+
# txreceipt_status: "1",
|
|
57
|
+
# input: "0x",
|
|
58
|
+
# contractAddress: "",
|
|
59
|
+
# cumulativeGasUsed: "156270",
|
|
60
|
+
# gasUsed: "21000",
|
|
61
|
+
# confirmations: "154"
|
|
62
|
+
# }
|
|
63
|
+
# ]
|
|
64
|
+
# }
|
|
65
|
+
def convert_transactions(transaction, _address)
|
|
66
|
+
{
|
|
67
|
+
tx_hash: transaction['hash'],
|
|
68
|
+
block_hash: transaction['block_hash'],
|
|
69
|
+
block_time: nil, # Not supported
|
|
70
|
+
estimated_tx_time: Time.at(transaction['timeStamp'].to_i).iso8601,
|
|
71
|
+
estimated_tx_value: transaction['value'].to_i, # Units here are 'Wei'
|
|
72
|
+
confirmations: transaction['confirmations'].to_i
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module CryptocoinPayable
|
|
2
|
+
module Adapters
|
|
3
|
+
def self.for(coin_type)
|
|
4
|
+
case coin_type.to_sym
|
|
5
|
+
when :eth
|
|
6
|
+
ethereum_adapter
|
|
7
|
+
when :btc
|
|
8
|
+
bitcoin_adapter
|
|
9
|
+
else
|
|
10
|
+
raise "Invalid coin type #{coin_type}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.ethereum_adapter
|
|
15
|
+
@ethereum_adapter ||= Ethereum.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.bitcoin_adapter
|
|
19
|
+
@bitcoin_adapter ||= Bitcoin.new
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
require 'cryptocoin_payable/adapters/base'
|
|
25
|
+
require 'cryptocoin_payable/adapters/bitcoin'
|
|
26
|
+
require 'cryptocoin_payable/adapters/bitcoin_cash'
|
|
27
|
+
require 'cryptocoin_payable/adapters/ethereum'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
require 'money-tree'
|
|
2
|
+
require 'state_machine'
|
|
3
|
+
|
|
4
|
+
module CryptocoinPayable
|
|
5
|
+
class CoinPayment < ActiveRecord::Base
|
|
6
|
+
belongs_to :payable, polymorphic: true
|
|
7
|
+
|
|
8
|
+
has_many :transactions, class_name: 'CryptocoinPayable::CoinPaymentTransaction' do
|
|
9
|
+
def create_from_tx_data!(tx_data, coin_conversion)
|
|
10
|
+
create!(
|
|
11
|
+
estimated_value: tx_data[:estimated_tx_value],
|
|
12
|
+
transaction_hash: tx_data[:tx_hash],
|
|
13
|
+
block_hash: tx_data[:block_hash],
|
|
14
|
+
block_time: tx_data[:block_time],
|
|
15
|
+
estimated_time: tx_data[:estimated_tx_time],
|
|
16
|
+
coin_conversion: coin_conversion,
|
|
17
|
+
confirmations: tx_data[:confirmations]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
validates :reason, presence: true
|
|
23
|
+
validates :price, presence: true
|
|
24
|
+
validates :coin_type, presence: true
|
|
25
|
+
|
|
26
|
+
before_create :populate_currency_and_amount_due
|
|
27
|
+
after_create :populate_address
|
|
28
|
+
|
|
29
|
+
scope :unconfirmed, -> { where(state: %i[pending partial_payment paid_in_full]) }
|
|
30
|
+
scope :unpaid, -> { where(state: %i[pending partial_payment]) }
|
|
31
|
+
scope :stale, -> { where('updated_at < ? OR coin_amount_due = 0', 30.minutes.ago) }
|
|
32
|
+
|
|
33
|
+
# TODO: Duplicated in `CurrencyConversion`.
|
|
34
|
+
enum coin_type: %i[
|
|
35
|
+
btc
|
|
36
|
+
eth
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
state_machine :state do
|
|
40
|
+
state :pending
|
|
41
|
+
state :partial_payment
|
|
42
|
+
state :paid_in_full
|
|
43
|
+
state :confirmed
|
|
44
|
+
state :comped
|
|
45
|
+
state :expired
|
|
46
|
+
|
|
47
|
+
after_transition on: :pay, do: :notify_payable_paid
|
|
48
|
+
after_transition on: :comp, do: :notify_payable_paid
|
|
49
|
+
after_transition on: :confirm, do: :notify_payable_confirmed
|
|
50
|
+
after_transition on: :expire, do: :notify_payable_expired
|
|
51
|
+
|
|
52
|
+
event :pay do
|
|
53
|
+
transition %i[pending partial_payment] => :paid_in_full
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
event :partially_pay do
|
|
57
|
+
transition pending: :partial_payment
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
event :comp do
|
|
61
|
+
transition %i[pending partial_payment] => :comped
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
event :confirm do
|
|
65
|
+
transition paid_in_full: :confirmed
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
event :expire do
|
|
69
|
+
transition [:pending] => :expired
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def coin_amount_paid
|
|
74
|
+
transactions.sum { |tx| adapter.convert_subunit_to_main(tx.estimated_value) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def coin_amount_paid_subunit
|
|
78
|
+
transactions.sum(&:estimated_value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @returns cents in fiat currency.
|
|
82
|
+
def currency_amount_paid
|
|
83
|
+
cents = transactions.inject(0) do |sum, tx|
|
|
84
|
+
sum + (adapter.convert_subunit_to_main(tx.estimated_value) * tx.coin_conversion)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Round to 0 decimal places so there aren't any partial cents.
|
|
88
|
+
cents.round(0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def currency_amount_due
|
|
92
|
+
price - currency_amount_paid
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def calculate_coin_amount_due
|
|
96
|
+
rate = CurrencyConversion.where(coin_type: coin_type).last.price
|
|
97
|
+
adapter.convert_main_to_subunit(currency_amount_due / rate.to_f).ceil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def transactions_confirmed?
|
|
101
|
+
transactions.all? do |t|
|
|
102
|
+
t.confirmations >= CryptocoinPayable.configuration.send(coin_type).confirmations
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def adapter
|
|
107
|
+
@adapter ||= Adapters.for(coin_type)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def populate_currency_and_amount_due
|
|
113
|
+
self.currency ||= CryptocoinPayable.configuration.currency
|
|
114
|
+
self.coin_amount_due = calculate_coin_amount_due
|
|
115
|
+
self.coin_conversion = CurrencyConversion.where(coin_type: coin_type).last.price
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def populate_address
|
|
119
|
+
update(address: adapter.create_address(id))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def notify_payable_event(event_name)
|
|
123
|
+
method_name = :"coin_payment_#{event_name}"
|
|
124
|
+
payable.send(method_name, self) if payable.respond_to?(method_name)
|
|
125
|
+
|
|
126
|
+
payable.coin_payment_event(self, event_name) if payable.respond_to?(:coin_payment_event)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def notify_payable_paid
|
|
130
|
+
notify_payable_event(:paid)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def notify_payable_confirmed
|
|
134
|
+
notify_payable_event(:confirmed)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def notify_payable_expired
|
|
138
|
+
notify_payable_event(:expired)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module CryptocoinPayable
|
|
2
|
+
class PaymentProcessor
|
|
3
|
+
def self.perform
|
|
4
|
+
new.perform
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def self.update_transactions_for(payment)
|
|
8
|
+
transactions = Adapters.for(payment.coin_type).fetch_transactions(payment.address)
|
|
9
|
+
|
|
10
|
+
transactions.each do |tx|
|
|
11
|
+
tx.symbolize_keys!
|
|
12
|
+
|
|
13
|
+
transaction = payment.transactions.find_by_transaction_hash(tx[:txHash])
|
|
14
|
+
if transaction
|
|
15
|
+
transaction.update(confirmations: tx[:confirmations])
|
|
16
|
+
else
|
|
17
|
+
payment.transactions.create_from_tx_data!(tx, payment.coin_conversion)
|
|
18
|
+
payment.update(
|
|
19
|
+
coin_amount_due: payment.calculate_coin_amount_due,
|
|
20
|
+
coin_conversion: CurrencyConversion.where(coin_type: payment.coin_type).last.price
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def perform
|
|
27
|
+
CoinPayment.unconfirmed.find_each do |payment|
|
|
28
|
+
# Check for completed payment first, in case it's 0 and we don't need to
|
|
29
|
+
# make an API call.
|
|
30
|
+
update_payment_state(payment)
|
|
31
|
+
|
|
32
|
+
next if payment.confirmed?
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
self.class.update_transactions_for(payment)
|
|
36
|
+
rescue StandardError => error
|
|
37
|
+
STDERR.puts 'PaymentProcessor: Unknown error encountered, skipping transaction'
|
|
38
|
+
STDERR.puts error
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Check for payments after the response comes back.
|
|
43
|
+
update_payment_state(payment)
|
|
44
|
+
|
|
45
|
+
# If the payment has not moved out of the pending state after loading
|
|
46
|
+
# new transactions, we expire it.
|
|
47
|
+
update_payment_expired_state(payment) if payment.pending?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
def update_payment_state(payment)
|
|
54
|
+
if payment.currency_amount_paid >= payment.price
|
|
55
|
+
payment.pay
|
|
56
|
+
payment.confirm if payment.transactions_confirmed?
|
|
57
|
+
elsif payment.currency_amount_paid > 0
|
|
58
|
+
payment.partially_pay
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def update_payment_expired_state(payment)
|
|
63
|
+
expire_after = CryptocoinPayable.configuration.expire_payments_after
|
|
64
|
+
payment.expire if expire_after.present? && (Time.now - payment.created_at) >= expire_after
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module CryptocoinPayable
|
|
2
|
+
class PricingProcessor
|
|
3
|
+
def self.perform
|
|
4
|
+
new.perform
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def self.delete_currency_conversions(time_ago)
|
|
8
|
+
new.delete_currency_conversions(time_ago)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def perform
|
|
12
|
+
rates = CurrencyConversion.coin_types.map do |coin_pair|
|
|
13
|
+
coin_type = coin_pair[0].to_sym
|
|
14
|
+
[
|
|
15
|
+
coin_type,
|
|
16
|
+
CurrencyConversion.create!(
|
|
17
|
+
# TODO: Store three previous price ranges, defaulting to 100 for now.
|
|
18
|
+
currency: 100,
|
|
19
|
+
price: Adapters.for(coin_type).fetch_rate,
|
|
20
|
+
coin_type: coin_type
|
|
21
|
+
)
|
|
22
|
+
]
|
|
23
|
+
end.to_h
|
|
24
|
+
|
|
25
|
+
# Loop through all unpaid payments and update them with the new price if
|
|
26
|
+
# it has been 30 mins since they have been updated.
|
|
27
|
+
CoinPayment.unpaid.stale.find_each do |payment|
|
|
28
|
+
payment.update!(
|
|
29
|
+
coin_amount_due: payment.calculate_coin_amount_due,
|
|
30
|
+
coin_conversion: rates[payment.coin_type.to_sym].price
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete_currency_conversions(time_ago)
|
|
36
|
+
# Makes sure to keep at least one record in the db since other areas of
|
|
37
|
+
# the gem assume the existence of at least one record.
|
|
38
|
+
last_id = CurrencyConversion.last.id
|
|
39
|
+
time = time_ago || 1.month.ago
|
|
40
|
+
CurrencyConversion.where('created_at < ? AND id != ?', time, last_id).delete_all
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module CryptocoinPayable
|
|
2
|
+
class << self
|
|
3
|
+
attr_accessor :configuration
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def self.configure
|
|
7
|
+
@configuration ||= Configuration.new
|
|
8
|
+
yield(configuration)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Configuration
|
|
12
|
+
attr_accessor :testnet, :expire_payments_after, :request_delay, :btc, :bch, :eth
|
|
13
|
+
attr_writer :currency
|
|
14
|
+
|
|
15
|
+
def currency
|
|
16
|
+
@currency ||= :usd
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configure_btc
|
|
20
|
+
@btc ||= BtcConfiguration.new
|
|
21
|
+
yield(@btc)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure_bch
|
|
25
|
+
@bch ||= BchConfiguration.new
|
|
26
|
+
yield(@bch)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure_eth
|
|
30
|
+
@eth ||= EthConfiguration.new
|
|
31
|
+
yield(@eth)
|
|
32
|
+
|
|
33
|
+
Eth.configure do |config|
|
|
34
|
+
config.chain_id = CryptocoinPayable.configuration.testnet ? 4 : 1
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class CoinConfiguration
|
|
39
|
+
attr_accessor :master_public_key, :confirmations, :adapter_api_key
|
|
40
|
+
attr_writer :node_path
|
|
41
|
+
|
|
42
|
+
def node_path
|
|
43
|
+
@node_path ||= ''
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class BtcConfiguration < CoinConfiguration
|
|
48
|
+
def confirmations
|
|
49
|
+
@confirmations ||= 3
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class BchConfiguration < CoinConfiguration
|
|
54
|
+
def confirmations
|
|
55
|
+
@confirmations ||= 3
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class EthConfiguration < CoinConfiguration
|
|
60
|
+
def confirmations
|
|
61
|
+
@confirmations ||= 12
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module CryptocoinPayable
|
|
2
|
+
module Model
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.send :extend, ClassMethods
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def has_coin_payments(_options = {}) # rubocop:disable Naming/PredicateName
|
|
9
|
+
has_many :coin_payments, -> { order(:id) },
|
|
10
|
+
class_name: 'CryptocoinPayable::CoinPayment',
|
|
11
|
+
as: 'payable'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require 'rake'
|
|
2
|
+
require 'cryptocoin_payable/commands/pricing_processor'
|
|
3
|
+
require 'cryptocoin_payable/commands/payment_processor'
|
|
4
|
+
|
|
5
|
+
namespace :cryptocoin_payable do
|
|
6
|
+
desc 'Process the prices and update the payments'
|
|
7
|
+
task process_prices: :environment do
|
|
8
|
+
CryptocoinPayable::PricingProcessor.perform
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc 'Delete old CurrencyConversion data (will delete last month by default)'
|
|
12
|
+
task delete_currency_conversions: :environment do
|
|
13
|
+
CryptocoinPayable::PricingProcessor.delete_currency_conversions(ENV['DELETE_BEFORE'])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc 'Get transactions from external API and process payments'
|
|
17
|
+
task process_payments: :environment do
|
|
18
|
+
CryptocoinPayable::PaymentProcessor.perform
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
|
|
3
|
+
require 'cryptocoin_payable/config'
|
|
4
|
+
require 'cryptocoin_payable/errors'
|
|
5
|
+
require 'cryptocoin_payable/version'
|
|
6
|
+
require 'cryptocoin_payable/has_coin_payments'
|
|
7
|
+
require 'cryptocoin_payable/tasks'
|
|
8
|
+
require 'cryptocoin_payable/adapters'
|
|
9
|
+
require 'cryptocoin_payable/coin_payment_transaction'
|
|
10
|
+
require 'cryptocoin_payable/coin_payment'
|
|
11
|
+
require 'cryptocoin_payable/currency_conversion'
|
|
12
|
+
|
|
13
|
+
module CryptocoinPayable
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
ActiveSupport.on_load(:active_record) do
|
|
17
|
+
include CryptocoinPayable::Model
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'rails/generators'
|
|
2
|
+
require 'rails/generators/active_record'
|
|
3
|
+
|
|
4
|
+
module CryptocoinPayable
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
include Rails::Generators::Migration
|
|
7
|
+
|
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
|
9
|
+
|
|
10
|
+
desc 'Generates (but does not run) a migration to add a coin payment tables.'
|
|
11
|
+
|
|
12
|
+
def create_migration_file
|
|
13
|
+
migration_template 'create_coin_payments.rb',
|
|
14
|
+
'db/migrate/create_coin_payments.rb'
|
|
15
|
+
|
|
16
|
+
migration_template 'create_coin_payment_transactions.rb',
|
|
17
|
+
'db/migrate/create_coin_payment_transactions.rb'
|
|
18
|
+
|
|
19
|
+
migration_template 'create_currency_conversions.rb',
|
|
20
|
+
'db/migrate/create_currency_conversions.rb'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.next_migration_number(dirname)
|
|
24
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateCoinPaymentTransactions < ActiveRecord::Migration[5.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :coin_payment_transactions do |t|
|
|
4
|
+
t.decimal :estimated_value, precision: 24, scale: 0
|
|
5
|
+
t.string :transaction_hash
|
|
6
|
+
t.string :block_hash
|
|
7
|
+
t.datetime :block_time
|
|
8
|
+
t.datetime :estimated_time
|
|
9
|
+
t.integer :coin_payment_id
|
|
10
|
+
t.decimal :coin_conversion, precision: 24, scale: 0
|
|
11
|
+
t.integer :confirmations, default: 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :coin_payment_transactions, :coin_payment_id
|
|
15
|
+
end
|
|
16
|
+
end
|