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.
Files changed (103) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +36 -0
  5. data/Gemfile +8 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +237 -0
  8. data/Rakefile +3 -0
  9. data/bin/console +15 -0
  10. data/bin/setup +8 -0
  11. data/cryptocoin_payable.gemspec +39 -0
  12. data/features/coin_payments.feature +51 -0
  13. data/features/default.feature +5 -0
  14. data/features/pricing_processor.feature +4 -0
  15. data/features/step_definitions/coin_payment_steps.rb +45 -0
  16. data/features/step_definitions/currency_conversion_steps.rb +12 -0
  17. data/features/step_definitions/model_step.rb +11 -0
  18. data/features/step_definitions/processor_steps.rb +7 -0
  19. data/features/step_definitions/widget_steps.rb +3 -0
  20. data/features/support/env.rb +26 -0
  21. data/lib/cryptocoin_payable/adapters/base.rb +99 -0
  22. data/lib/cryptocoin_payable/adapters/bitcoin.rb +93 -0
  23. data/lib/cryptocoin_payable/adapters/bitcoin_cash.rb +34 -0
  24. data/lib/cryptocoin_payable/adapters/ethereum.rb +77 -0
  25. data/lib/cryptocoin_payable/adapters.rb +27 -0
  26. data/lib/cryptocoin_payable/coin_payment.rb +141 -0
  27. data/lib/cryptocoin_payable/coin_payment_transaction.rb +5 -0
  28. data/lib/cryptocoin_payable/commands/payment_processor.rb +67 -0
  29. data/lib/cryptocoin_payable/commands/pricing_processor.rb +43 -0
  30. data/lib/cryptocoin_payable/config.rb +65 -0
  31. data/lib/cryptocoin_payable/currency_conversion.rb +11 -0
  32. data/lib/cryptocoin_payable/errors.rb +7 -0
  33. data/lib/cryptocoin_payable/has_coin_payments.rb +15 -0
  34. data/lib/cryptocoin_payable/tasks.rb +20 -0
  35. data/lib/cryptocoin_payable/version.rb +3 -0
  36. data/lib/cryptocoin_payable.rb +18 -0
  37. data/lib/generators/cryptocoin_payable/install_generator.rb +27 -0
  38. data/lib/generators/cryptocoin_payable/templates/create_coin_payment_transactions.rb +16 -0
  39. data/lib/generators/cryptocoin_payable/templates/create_coin_payments.rb +19 -0
  40. data/lib/generators/cryptocoin_payable/templates/create_currency_conversions.rb +11 -0
  41. data/spec/acceptance/adapters/bitcoin_cash_spec.rb +54 -0
  42. data/spec/acceptance/adapters/bitcoin_spec.rb +79 -0
  43. data/spec/acceptance/adapters/ethereum_spec.rb +77 -0
  44. data/spec/dummy/README.rdoc +28 -0
  45. data/spec/dummy/Rakefile +6 -0
  46. data/spec/dummy/app/assets/images/.keep +0 -0
  47. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  48. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  49. data/spec/dummy/app/controllers/application_controller.rb +5 -0
  50. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  51. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  52. data/spec/dummy/app/mailers/.keep +0 -0
  53. data/spec/dummy/app/models/.keep +0 -0
  54. data/spec/dummy/app/models/concerns/.keep +0 -0
  55. data/spec/dummy/app/models/widget.rb +3 -0
  56. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  57. data/spec/dummy/bin/bundle +3 -0
  58. data/spec/dummy/bin/rails +4 -0
  59. data/spec/dummy/bin/rake +4 -0
  60. data/spec/dummy/config/application.rb +22 -0
  61. data/spec/dummy/config/boot.rb +5 -0
  62. data/spec/dummy/config/database.yml +25 -0
  63. data/spec/dummy/config/environment.rb +5 -0
  64. data/spec/dummy/config/environments/development.rb +29 -0
  65. data/spec/dummy/config/environments/production.rb +80 -0
  66. data/spec/dummy/config/environments/test.rb +36 -0
  67. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  68. data/spec/dummy/config/initializers/cryptocoin_payable.rb +23 -0
  69. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  70. data/spec/dummy/config/initializers/inflections.rb +16 -0
  71. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  72. data/spec/dummy/config/initializers/secret_token.rb +14 -0
  73. data/spec/dummy/config/initializers/session_store.rb +3 -0
  74. data/spec/dummy/config/initializers/state_machine.rb +10 -0
  75. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  76. data/spec/dummy/config/locales/en.yml +23 -0
  77. data/spec/dummy/config/routes.rb +56 -0
  78. data/spec/dummy/config.ru +4 -0
  79. data/spec/dummy/db/development.sqlite3 +0 -0
  80. data/spec/dummy/db/migrate/20140510023211_create_widgets.rb +5 -0
  81. data/spec/dummy/db/migrate/20171227225132_create_coin_payments.rb +19 -0
  82. data/spec/dummy/db/migrate/20171227225133_create_coin_payment_transactions.rb +16 -0
  83. data/spec/dummy/db/migrate/20171227225134_create_currency_conversions.rb +11 -0
  84. data/spec/dummy/db/schema.rb +54 -0
  85. data/spec/dummy/lib/assets/.keep +0 -0
  86. data/spec/dummy/log/.keep +0 -0
  87. data/spec/dummy/public/404.html +58 -0
  88. data/spec/dummy/public/422.html +58 -0
  89. data/spec/dummy/public/500.html +57 -0
  90. data/spec/dummy/public/favicon.ico +0 -0
  91. data/spec/dummy/test/fixtures/widgets.yml +11 -0
  92. data/spec/dummy/test/models/widget_test.rb +7 -0
  93. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/gets_an_empty_result_when_no_transactions_found.yml +67 -0
  94. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/gets_transactions_for_a_given_address.yml +73 -0
  95. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/raises_an_error_when_an_invalid_address_is_passed.yml +103 -0
  96. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Bitcoin/when_the_Block_Explorer_API_fails/falls_back_to_using_the_BlockCypher_API.yml +171 -0
  97. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_BitcoinCash/gets_an_empty_result_when_no_transactions_found.yml +62 -0
  98. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_BitcoinCash/gets_transactions_for_a_given_address.yml +65 -0
  99. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Ethereum/gets_an_empty_result_when_no_transactions_found.yml +44 -0
  100. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Ethereum/gets_transactions_for_a_given_address.yml +44 -0
  101. data/spec/fixtures/vcr_cassettes/CryptocoinPayable_Adapters_Ethereum/raises_an_error_when_an_invalid_address_is_passed.yml +44 -0
  102. data/spec/spec_helper.rb +112 -0
  103. 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,5 @@
1
+ module CryptocoinPayable
2
+ class CoinPaymentTransaction < ActiveRecord::Base
3
+ belongs_to :coin_payment
4
+ end
5
+ 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,11 @@
1
+ module CryptocoinPayable
2
+ class CurrencyConversion < ActiveRecord::Base
3
+ validates :price, presence: true
4
+
5
+ # TODO: Duplicated in `CoinPayment`.
6
+ enum coin_type: %i[
7
+ btc
8
+ eth
9
+ ]
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module CryptocoinPayable
2
+ class ApiError < StandardError; end
3
+ class ApiLimitReached < ApiError; end
4
+ class ConfigError < StandardError; end
5
+ class MissingMasterPublicKey < ConfigError; end
6
+ class NetworkNotSupported < ConfigError; end
7
+ 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,3 @@
1
+ module CryptocoinPayable
2
+ VERSION = '1.0.0'.freeze
3
+ 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