M .rubocop.yml => .rubocop.yml +3 -0
@@ 1,3 1,6 @@
+AllCops:
+ TargetRubyVersion: 2.3
+
Metrics/LineLength:
Max: 80
M Gemfile => Gemfile +1 -0
@@ 4,6 4,7 @@ source "https://rubygems.org"
gem "braintree"
gem "dhall"
+gem "money-open-exchange-rates"
gem "pg"
gem "redis"
gem "roda"
A bin/process_pending_btc_transactions => bin/process_pending_btc_transactions +98 -0
@@ 0,0 1,98 @@
+#!/usr/bin/ruby
+# frozen_string_literal: true
+
+# Usage: bin/process_pending-btc_transactions '{
+# oxr_app_id = "",
+# required_confirmations = 3,
+# electrum = env:ELECTRUM_CONFIG,
+# plans = ./plans.dhall
+# }'
+
+require "bigdecimal"
+require "dhall"
+require "money/bank/open_exchange_rates_bank"
+require "net/http"
+require "nokogiri"
+require "pg"
+require "redis"
+
+require_relative "../lib/electrum"
+
+CONFIG =
+ Dhall::Coder
+ .new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
+ .load(ARGV[0], transform_keys: :to_sym)
+
+REDIS = Redis.new
+ELECTRUM = Electrum.new(**CONFIG[:electrum])
+
+DB = PG.connect(dbname: "jmp")
+DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
+DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
+
+unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
+ oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
+ oxr.app_id = CONFIG.fetch(:oxr_app_id)
+ oxr.update_rates
+ cad_to_usd = oxr.get_rate("CAD", "USD")
+ REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60)
+end
+
+canadianbitcoins = Nokogiri::HTML.parse(
+ Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
+)
+
+bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
+raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
+
+btc_sell_price = {}
+btc_sell_price[:CAD] = BigDecimal.new(
+ bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
+)
+btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
+
+class Plan
+ def self.for_customer(customer_id)
+ row = DB.exec_params(<<-SQL, [customer_id]).first
+ SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
+ SQL
+ return unless row
+ plan = CONFIG[:plans].find { |p| p["plan_name"] = row["plan_name"] }
+ new(plan) if plan
+ end
+
+ def initialize(plan)
+ @plan = plan
+ end
+
+ def currency
+ @plan[:currency]
+ end
+end
+
+REDIS.hgetall("pending_btc_transactions").each do |(txid, customer_id)|
+ tx_hash, address = txid.split("/", 2)
+ transaction = ELECTRUM.gettransaction(tx_hash)
+ next unless transaction.confirmations >= CONFIG[:required_confirmations]
+ btc = transaction.amount_for(address)
+ if btc <= 0
+ warn "Transaction shows as #{btc}, skipping #{txid}"
+ next
+ end
+ DB.transaction do
+ plan = Plan.for_customer(customer_id)
+ if plan
+ amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
+ DB.exec_params(<<-SQL, [customer_id, txid, amount])
+ INSERT INTO transactions
+ (customer_id, transaction_id, amount, note)
+ VALUES
+ ($1, $2, $3, 'Bitcoin payment')
+ ON CONFLICT (transaction_id) DO NOTHING
+ SQL
+ else
+ warn "No plan for #{customer_id} cannot save #{txid}"
+ end
+ end
+ REDIS.hdel("pending_btc_transactions", txid)
+end
M lib/electrum.rb => lib/electrum.rb +33 -0
@@ 1,5 1,6 @@
# frozen_string_literal: true
+require "bigdecimal"
require "json"
require "net/http"
require "securerandom"
@@ 15,6 16,38 @@ class Electrum
rpc_call(:getaddresshistory, address: address)["result"]
end
+ def gettransaction(tx_hash)
+ Transaction.new(self, tx_hash, rpc_call(
+ :deserialize,
+ [rpc_call(:gettransaction, txid: tx_hash)["result"]]
+ )["result"])
+ end
+
+ def get_tx_status(tx_hash)
+ rpc_call(:get_tx_status, txid: tx_hash)["result"]
+ end
+
+ class Transaction
+ def initialize(electrum, tx_hash, tx)
+ @electrum = electrum
+ @tx_hash = tx_hash
+ @tx = tx
+ end
+
+ def confirmations
+ @electrum.get_tx_status(@tx_hash)["confirmations"]
+ end
+
+ def amount_for(*addresses)
+ BigDecimal.new(
+ @tx["outputs"]
+ .select { |o| addresses.include?(o["address"]) }
+ .map { |o| o["value_sats"] }
+ .sum
+ ) * 0.00000001
+ end
+ end
+
protected
def rpc_call(method, params)